From 47e5acaec4af17bbf5cfa1a01834a64e450e8cd6 Mon Sep 17 00:00:00 2001 From: River Lynn Bailey Date: Thu, 16 Jul 2026 12:13:23 -0600 Subject: [PATCH 01/22] docs(plans): add han-publishing-cleanup feature specification Specifies the seven-step cleanup from the source plan artifact: publish han-linear to the Codex channel, close the work-items-to-issues silent hole, repair the release process, correct the frozen Codex versions, delete the two decorative han-core dependencies, correct every falsified document, and turn on the publishing check. All seven steps are retained. Execution order is 1, 2, 6, 3, 4, 5, 7: the release repair moves ahead of the version correction, because the release only writes the Claude Code channel until repaired, so any release cut between the two would re-freeze the correction and land the check red. Deleting the dependencies edits two plugin directories, which forces both to bump at the next release, so the plan's own work triggers it. Reviewed by junior-developer, devops-engineer, edge-case-explorer, and information-architect, then synthesized by project-manager. Every specialist claim was verified against the files; three were verified and rejected. Review changed the spec substantially: the ordering was a partial order asserting five couplings it did not have while missing the one it did; the release gate had no placement and the natural reading put it after the pushed tag; "the check and release share one answer" had no bearer; the document survey was incomplete by five locations; and the count of version records was wrong (three, not two). --- .../artifacts/decision-log.md | 946 ++++++++++++++++++ .../artifacts/feature-technical-notes.md | 59 ++ .../artifacts/team-findings.md | 405 ++++++++ .../feature-specification.md | 397 ++++++++ 4 files changed, 1807 insertions(+) create mode 100644 docs/plans/han-publishing-cleanup/artifacts/decision-log.md create mode 100644 docs/plans/han-publishing-cleanup/artifacts/feature-technical-notes.md create mode 100644 docs/plans/han-publishing-cleanup/artifacts/team-findings.md create mode 100644 docs/plans/han-publishing-cleanup/feature-specification.md diff --git a/docs/plans/han-publishing-cleanup/artifacts/decision-log.md b/docs/plans/han-publishing-cleanup/artifacts/decision-log.md new file mode 100644 index 00000000..23f4fe0e --- /dev/null +++ b/docs/plans/han-publishing-cleanup/artifacts/decision-log.md @@ -0,0 +1,946 @@ +# Decision Log: Han Publishing Cleanup + +Spec: [../feature-specification.md](../feature-specification.md) · Findings: [team-findings.md](team-findings.md) · +Technical notes: [feature-technical-notes.md](feature-technical-notes.md) + +Trust classes used in `Evidence:` fields, per [evidence-rule.md](../../../../han-planning/references/evidence-rule.md): +**codebase** (read directly from files here), **provided** (supplied by the user, including the source artifact), **web** +(external). + +## Full decisions + +### D1: The check lands last, because a check that blocks everything gets disabled + +**Outcome.** The automated check becomes blocking only after every problem it detects has been fixed. This governs one +adjacency — everything before the check — not the whole sequence +([D18](#d18-the-execution-order-is-a-partial-order-and-the-repair-precedes-the-correction)). + +**Rationale.** The check is correct on day one; the repository is not ready for it. Landing it first fails it on nearly +every plugin and blocks every release and pull request immediately. The realistic response is to disable it, leaving the +repository with all the original problems plus one disabled check. + +Rescoped after review. The original entry generalized this argument into a total order over all seven steps, which it +does not support. It supports exactly this one constraint. See D18. + +Also clarified after review: this is about when the rule becomes **blocking**, not when it comes into existence. The +rule exists from the release repair onward, because the release runs it ([D14](#d14-the-release-runs-the-check-rather-than-restating-it)). + +**Evidence.** provided — the source artifact states it directly and records that an adversarial reviewer caught the +original ordering error. codebase — the check would fail today on `han-linear` (absent from +`.agents/plugins/marketplace.json`, no `.codex-plugin/`) and on all eight drifted codex version records. + +**Rejected alternatives.** + +- _Land the check first to prevent regressions during the work._ Rejected: it blocks the very pull requests that fix the + problems it detects. +- _Land the check disabled, enable it at the end._ Rejected: a disabled check is indistinguishable from no check, and + adds a step that can be forgotten. See also [D28](#d28-the-check-ships-with-no-disable-switch). + +**Driven by findings:** F1, F3 +**Linked technical notes:** — +**Dependent decisions:** D18, D28 +**Referenced in spec:** Outcome, Primary flow (Step 7), Alternate flows and states + +### D2: Step 2 closes the silent hole only; annotation namespacing is separate + +**Outcome.** Step 2 guarantees that no work item leaves a run unaccounted for. It does not change the annotation format +and requires no migration. Making each tracker's annotations name their tracker is a separate specification. + +**Rationale.** Two distinct defects share a neighborhood. The first is that two trackers write indistinguishable +annotations, so one skips the other's work — a trap, but a reported one: the skipped count surfaces it twice to a person +paying attention. The second is that the GitHub publisher matches neither pattern and drops the work items with no +signal at all. Only the second is silent, and only the second is step 2. The simpler version — account for every work +item — satisfies the evidence that drove the step here, without a format change or a migration for files already in +people's repositories. + +**Evidence.** codebase — `han-github/skills/work-items-to-issues/scripts/create-issues.sh` creates by matching +`^## [A-Z][A-Z0-9]*-[0-9]+ — ` and counts skipped by matching `^## [A-Z][A-Z0-9]*-[0-9]+ \(#[0-9]+\) — `. A heading +annotated `## W-1 (ACME-142) — title` matches neither, so it is never created and never counted. codebase — +`han-atlassian/.../work-items-file-format.md` writes `()` and `han-linear/.../work-items-file-format.md` +writes `()`; both are `[A-Z]+-[0-9]+`, hence mutually indistinguishable. provided — the user selected this +scope explicitly. + +**Rejected alternatives.** + +- _Namespace all three trackers' annotations in step 2._ Rejected: the simpler version satisfies the same evidence, and + the larger version adds a format change plus a migration for files already in people's repositories. +- _Fix nothing until namespacing is designed._ Rejected: the silent loss is live and closing it does not depend on the + format decision. + +**Driven by findings:** — +**Linked technical notes:** — +**Dependent decisions:** D3, D17, D30 +**Referenced in spec:** Primary flow (Step 2), Edge cases, Out of scope + +### D3: A foreign annotation stops the run before anything is created + +**Outcome.** When the GitHub publisher meets a work item annotated by a different tracker, it surfaces a format error +and stops. The whole file is examined before the first item is published, so "before anything is created" means nothing +in the entire run — not "nothing further from here on". + +**Rationale.** Settled from evidence: the skill already has this convention for this class of problem. A foreign +annotation almost certainly means the file was published to another tracker, so publishing it again would duplicate real +tickets — the more expensive error. + +Strengthened after review. The original entry said "stops before creating anything" without saying at what scope. The +create loop re-scans from the top each iteration for the first unannotated heading, so the natural incremental fix — +detect the foreign shape when the scan reaches it — would create every item *ahead* of it in file order and then halt. +Real tickets would exist, contradicting the all-or-nothing intent. Examining the whole file first is what makes the +commitment true. + +**Evidence.** codebase — `han-github/skills/work-items-to-issues/SKILL.md` establishes the convention: "A cross-repo +`Depends on` is a format error to surface for repair." codebase — the Linear and Jira format references establish the +same posture: self-blocks and dependency cycles are "surfaced for repair before any issue is created, never published". +codebase — `create-issues.sh`'s loop structure (`grep -nE ... | head -n 1` re-scanning per iteration) is what makes the +scope ambiguity real rather than theoretical. provided — the source artifact notes the existing posture "errs toward +stopping and asking rather than guessing". + +**Rejected alternatives.** + +- _Publish the work item anyway._ Rejected: it most likely already exists elsewhere, so this creates duplicates across + two trackers — worse than the silent drop it replaces. +- _Skip it and count it in the skipped total._ Rejected: conflates "already published here" with "already published + somewhere else", which is the exact confusion that produced the bug. +- _Warn and continue._ Rejected: contrary to the skill's all-or-nothing convention, and a warning inside a long run is + close to the signal-lessness being fixed. +- _Detect the foreign shape as the scan reaches it._ Rejected: simpler to implement, but it creates real tickets before + halting, which breaks the commitment the step exists to make. + +**Driven by findings:** F7 +**Linked technical notes:** — +**Dependent decisions:** D17, D30 +**Referenced in spec:** Primary flow (Step 2), Alternate flows and states + +### D4: The check blocks on every pull request, and runs locally where hooks are installed + +**Outcome.** The check blocks on every pull request. It additionally runs before a commit for contributors who have +installed the optional local hooks. The guarantee rests on the pull-request half; the local half is a convenience. + +**Rationale.** Rewritten after review. The original entry claimed the check "runs both before a commit lands and on +every pull request" and that a contributor therefore learns while still working. That is false for anyone who has not +opted in, and this repository's hooks are opt-in. Claiming the local half as a delivered behavior would have made the +spec promise something it cannot. + +The pull-request half is the real guarantee precisely because it does not depend on anyone's local setup. Adding the +check to the existing lint path means it rides both surfaces for free, so contributors who did install hooks get the +faster loop at no extra cost. + +**Evidence.** codebase — `package.json` defines `"lint": "prek run --all-files"` and `.github/workflows/ci.yml`'s lint +job runs `npm run lint` on every pull request and every push to `main`. codebase — `CONTRIBUTING.md:59` reads "If you +want pre-commit hooks, run `npx prek install`", so hooks are opt-in and absent from a fresh clone. codebase — +`.pre-commit-config.yaml` already carries local hooks and shellcheck, so a new local hook is the established pattern. +codebase — `test/sanity.bats` and `"test": "bats --recursive test/"` give the check a testing home. provided — the user +selected this option. + +**Rejected alternatives.** + +- _Claim the local half as a guarantee._ Rejected on evidence: hooks are opt-in, so the claim is false for the default + contributor. +- _Make hook installation mandatory._ Rejected: no evidence anyone wants it, and it changes the contributor setup + contract to buy a faster loop the pipeline already provides more reliably. +- _Pipeline only, with no local hook._ Rejected: the local run is free once the check is in the lint path, and it does + help the contributors who opted in. +- _Inside the release process only._ Rejected: drift could still land on the default branch between releases. + +**Driven by findings:** F11 +**Linked technical notes:** — +**Dependent decisions:** D14, D24 +**Referenced in spec:** Primary flow (Step 7), Coordinations + +### D5: The check derives the plugin list from the repository + +**Outcome.** Both the check and the release derive the set of plugins from what is actually in the repository, not from +a storefront listing. + +**Rationale.** This is the root cause, not a symptom. The release currently takes its plugin list from one of the targets +it also writes, which makes the list self-confirming: a plugin missing from that listing is invisible to the process +meant to notice it is missing. No amount of care during a release surfaces a problem the release cannot see. A plugin +added today is invisible on channel two by default, which is why this is a standing defect rather than a one-time slip. + +**Evidence.** codebase — `.claude/skills/han-release/SKILL.md` sources its plugin list from +`jq -r '.plugins[] | ...' .claude-plugin/marketplace.json` and instructs "Enumerate the plugins from `plugins` in Project +Context". Its Step 4 writes only `{source}/.claude-plugin/plugin.json` and that same `marketplace.json`; it contains zero +references to `.codex-plugin/` or `.agents/plugins/marketplace.json`. codebase — `han-linear` is absent from +`.agents/plugins/marketplace.json` and has no `.codex-plugin/`, and no release has ever reported it. provided — the +source artifact's "Before" diagram: "DISK -.-> |never consulted directly| REL". + +**Rejected alternatives.** + +- _Keep reading channel one's listing and cross-check the others against it._ Rejected: it preserves the self-confirming + list, so a plugin missing from that listing stays invisible to both the release and the check. +- _Maintain a separate hand-written list of plugins._ Rejected: a third list to keep in sync and a fifth thing that can + go stale. + +**Driven by findings:** — +**Linked technical notes:** — +**Dependent decisions:** D6, D12, D14, D19, D24 +**Referenced in spec:** Outcome, Primary flow (Step 3), Coordinations + +### D6: The bundle is a permanently named exception on the second channel + +**Outcome.** The bundle is a named, permanent exception. Its absence from channel two is never flagged, **and the +version-agreement rule does not apply to it** — a plugin published on one channel has nothing to disagree with. + +**Rationale.** The limitation is real, external, and documented with a specific upstream tracking issue. An exception the +rule does not know about would fire on every run forever, and a check that always reports one known failure trains people +to ignore it. + +Extended after review. The original entry stated the exception as presence-only. The bundle is also structurally exempt +from version agreement, which was implied rather than stated. + +**Evidence.** codebase — `README.md:74` states "Codex does not yet support meta-plugins like `han@han` (see +openai/codex#23531,) and it resolves no dependencies", and the Codex install instructions list every plugin individually +rather than the bundle. codebase — `.agents/plugins/marketplace.json` omits `han` and there is no `han/.codex-plugin/`, +consistent with a deliberate exclusion. codebase — `han/.claude-plugin/plugin.json` exists with no `skills/` directory at +all, which is what makes the bundle permanently zero-skill (see [D19](#d19-a-plugin-and-the-targets-it-belongs-in-are-defined-positively)). +provided — the source artifact: "the check needs to know about it permanently rather than flagging it forever." + +**Rejected alternatives.** + +- _A general exception mechanism configurable per plugin per target._ Rejected under YAGNI: one known exception does not + justify a mechanism. +- _Publish a bundle to channel two anyway._ Rejected: the channel does not support it. +- _Let the check flag it and have people ignore that one line._ Rejected: a permanently red check is a disabled check + with extra steps, which is the failure D1 exists to avoid. + +**Driven by findings:** F5 +**Linked technical notes:** — +**Dependent decisions:** D14, D19, D20 +**Referenced in spec:** Channels and targets, Primary flow (Step 3), Edge cases, Deferred (YAGNI) + +### D7: A document is in scope when this work falsifies it + +**Outcome.** The document correction's scope is a rule, not a list: a document is in scope when this work falsifies it. +Not "mentions a plugin", not "narrates topology", not "sits nearby". The step that falsifies a document owns it, so the +rule spans step 6 and step 3 rather than sitting inside step 6 +([D21](#d21-the-release-procedure-documents-are-owned-by-the-step-that-falsifies-them)). + +**Rationale.** The source plan said "the two documents that describe a behavior the system does not have" without naming +them. The first survey found seven locations across six documents. Review found five more, including the orientation +document new readers are told to read first — so the enumeration was wrong twice and would likely be wrong a third time. +A rule survives that; a list does not. + +The step's stated purpose is "so nobody makes a change based on them and gets it wrong". That purpose is served by the +set of falsified documents, not by a count. The rule also draws the exclusion cleanly: documents already stale for +unrelated reasons stay out, because "the editor is already open" is convenience, not evidence. + +**Evidence.** codebase — locations falsified by the declaration deletion, all verified verbatim. First survey: +`README.md:95`; `CLAUDE.md:19`, `:85`, `:119`; `CONTRIBUTING.md:128`; `docs/semantic-versioning.md:98`, `:100`; +`docs/choosing-a-han-plugin.md:119`; `docs/skills/README.md:167`; and +`docs/how-to/extend-han-with-plugin-dependencies.md` throughout. Found in review: `docs/concepts.md:221` ("Each of these +four depends on `han-core`") and `:229` ("Each of these three depends on `han-core` like the other layers"); +`docs/skills/han-feedback/han-feedback.md:66` ("it pulls `han-core` along the way"); +`docs/choosing-a-han-plugin.md:141` ("Both pull `han-core` along the same way the other layers do"); +`CONTRIBUTING.md:129` (the feedback plugin "may dispatch any `han-core` agent freely"); `docs/semantic-versioning.md:153` +("Every dependency in `han`'s and `han-feedback`'s `plugin.json` is a bare string"). codebase — correctly **excluded**: +`docs/semantic-versioning.md:114` describes the parent's dependencies, which this work does not touch, so it stays true. +codebase — `docs/plans/CLAUDE.md` states plan documents "should not be updated during documentation reviews and updates", +so `docs/plans/` and `docs/research/` are correctly frozen and narrate the pre-deletion topology permanently. provided — +the source artifact, which names a count but not the documents. + +**Rejected alternatives.** + +- _Fix literally two documents, as written._ Rejected: leaves ten locations wrong, including a tutorial and the + orientation doc. +- _Fix only the documents a user is likely to read._ Rejected: the contributor guide and the agent-facing project map are + read by exactly the audience whose wrong change the step exists to prevent. (Note the first survey's instinct was right + and its execution was not — it skipped the most-read document in the set.) +- _Enumerate the locations in the spec._ Rejected: the enumeration has been wrong twice already. It lives here, where it + is expected to move. + +**Driven by findings:** F8, F17 +**Linked technical notes:** — +**Dependent decisions:** D8, D9, D21, D25, D26 +**Referenced in spec:** Primary flow (Step 6), Out of scope + +### D8: The tutorial's worked example repoints to surviving real edges + +**Outcome.** `docs/how-to/extend-han-with-plugin-dependencies.md` keeps its worked-example structure and repoints it at +edges that remain true. The opt-in-leaf role passes from `han-feedback` to `han-linear`; the layer-on-top-of-core role +passes from `han-reporting` to `han-coding`. + +**Rationale.** The tutorial does not merely mention the two edges; it teaches by walking through them. The roles those +plugins play are still filled by real plugins, so the lesson survives intact with different subjects. `han-linear` is a +structural identity for `han-feedback`'s role: it depends on `han-core`, is not in the bundle's dependencies, and is +installed on its own. + +Rationale corrected after review. The original entry justified keeping real plugin names by citing the tutorial's claim +to print real on-disk versions. That claim is itself false, so it could not support anything. The real justification is +narrower and still sufficient: the tutorial teaches a topology, the topology still exists, and a reader can still go read +the real manifests being described. What the tutorial should stop claiming is handled separately in +[D27](#d27-the-tutorial-teaches-shape-and-stops-promising-real-version-numbers). + +**Evidence.** codebase — `han-linear/.claude-plugin/plugin.json` declares `"dependencies": ["han-core"]` and is absent +from `han/.claude-plugin/plugin.json`'s dependencies, matching the tutorial's ":169" role ("a leaf that nothing else +points to: it depends on core, but the meta-plugin does not bundle it"). codebase — +`han-coding/.claude-plugin/plugin.json` declares `["han-communication", "han-core"]`, matching the ":108" +"second layer on top of core" role. codebase — the tutorial never mentions Codex; it teaches `.claude-plugin/plugin.json` +and `/plugin install …@han` throughout, so `han-linear`'s channel-two absence does not undercut it as an exemplar — and +step 1 publishes it there before step 6 runs anyway. provided — the user selected this option. + +**Rejected alternatives.** + +- _Rewrite around invented plugin names._ Rejected: more durable against future topology changes, but it removes the + reader's ability to go read the real manifests, which is the tutorial's whole method. +- _Leave it and add a correction note._ Rejected: a tutorial with a note saying its example is wrong is a tutorial nobody + should follow. +- _Delete the tutorial._ Rejected: no evidence it is unwanted; the topology it teaches still exists. + +**Driven by findings:** F9 +**Linked technical notes:** — +**Dependent decisions:** D27 +**Referenced in spec:** Primary flow (Step 6) + +### D9: The already-false contributor claim is in scope + +**Outcome.** `CONTRIBUTING.md:128` ("Every plugin depends on `han-core`") and the sentence it governs at `:129` are +corrected as part of step 6, though both were already false before the declaration deletion. + +**Rationale.** Re-grounded after review. The original rationale was "correcting it while already editing the same file +set is cheaper than a separate pass" — a convenience argument that generalizes straight into "and while we're here, fix +everything nearby", which is the symmetry reasoning D7's rule exists to reject. + +The actual evidence is simpler and sufficient: the claim is false, and contributors read it before changing a plugin. +`:129` is the more dangerous of the two and was missed by the first survey: it is the operative sentence a contributor +acts on, and it tells them the feedback plugin "may dispatch any `han-core` agent freely" — which it structurally cannot. + +**Evidence.** codebase — `CONTRIBUTING.md:128` reads "**Every plugin depends on `han-core`,**" and `:129` continues "so a +skill in `han-planning`, `han-coding`, `han-github`, `han-reporting`, or `han-feedback` may dispatch any `han-core` agent +freely". codebase — `han-communication/.claude-plugin/plugin.json` and `han-plugin-builder/.claude-plugin/plugin.json` +declare no `dependencies` key at all, so `:128` is already false for two plugins. codebase — +`han-feedback/skills/han-feedback/SKILL.md:9` has no `Agent` tool, so `:129` is already false for the feedback plugin. +provided — the user selected this option. + +**Rejected alternatives.** + +- _Out of scope; file a separate issue._ Rejected: the step's purpose is "so nobody makes a change based on them and gets + it wrong", and this is the document that tells people how to make changes. + +**Driven by findings:** F8, F10 +**Linked technical notes:** — +**Dependent decisions:** D26 +**Referenced in spec:** Primary flow (Step 6) + +### D10: The two channels publish one version per plugin + +**Outcome.** Each plugin has one version. Channel two's published version for a plugin is set to match channel one's for +that same plugin, and the check enforces agreement from then on. + +**Rationale.** Two independent version lines for one plugin means two things to keep in sync and two ways to answer "what +version am I running?". The plugin is the same on both channels; the version describes the plugin, not the channel. + +**Evidence.** provided — the source artifact's "After" diagram labels channel two's version record "Version numbers, +copied from channel one". codebase — every codex version currently disagrees with its claude counterpart, and in the same +direction (all lower): `han-core` 1.2.0 vs 2.2.1, `han-coding` 1.0.0 vs 2.6.0, `han-github` 1.2.0 vs 2.2.2, +`han-reporting` 1.0.1 vs 2.1.1, `han-planning` 1.0.0 vs 2.0.4, `han-atlassian` 1.1.0 vs 2.2.0, `han-feedback` 1.1.1 vs +2.0.0, `han-plugin-builder` 1.1.0 vs 2.0.5. Only `han-communication` agrees (1.0.0), and only because it is new enough +never to have drifted — which corroborates that the drift is neglect rather than an intentional separate line. + +**Rejected alternatives.** + +- _Keep independent version lines and let the release bump each._ Rejected: no evidence anyone wants two lines, and + `han-communication` agreeing at 1.0.0 shows the lines only ever diverge through neglect. +- _Reset both channels to a common lower version._ Rejected: channel one is healthy and already published; moving it + backward breaks the healthy channel to tidy the broken one. + +**Driven by findings:** — +**Linked technical notes:** T1 +**Dependent decisions:** D20, D22 +**Referenced in spec:** Primary flow (Step 4) + +### D11: Step 1 goes first because it is a live broken promise + +**Outcome.** Publishing the Linear plugin to channel two is the first step. Unaffected by the reorder at D18. + +**Rationale.** It is the only step where a person following the project's own written instructions hits an error right +now. Every other step is a latent defect: a trap not yet sprung, a silence not yet noticed, a document not yet acted on. +This one fails on contact, in the first thing a new Linear user tries. + +**Evidence.** codebase — `README.md:87` tells channel-two users "Install `han-feedback`, `han-atlassian`, `han-linear`, +or `han-plugin-builder` separately", but `han-linear` has no `.codex-plugin/` and does not appear in +`.agents/plugins/marketplace.json`, so the documented command errors. codebase — the eight other plugins each have a +`.codex-plugin/plugin.json`, so `han-linear` is the only advertised-but-unpublished one. provided — the source artifact: +"It was added two days before the pass that would have published it, and that pass simply missed it." + +**Rejected alternatives.** + +- _Fix the release process first, then let it publish the Linear plugin._ Rejected: it makes the live user-facing error + wait on the largest step in the plan. Step 1 is a version record plus a listing entry. + +**Driven by findings:** — +**Linked technical notes:** — +**Dependent decisions:** D18, D22 +**Referenced in spec:** Primary flow (Step 1) + +### D12: A missing plugin stops the release, and every gap is named + +**Outcome.** When the release's gate finds a plugin missing from a target it belongs in, or version records that +disagree, it stops and names every gap it found rather than the first. + +**Rationale.** The failure being fixed is that the process shipped around a gap for roughly twenty releases. A warning is +what shipping around a gap looks like when someone has been told about it. + +Evidence corrected after review. The original entry justified "name every gap" by citing today's state — one missing +plugin plus eight version mismatches. But steps 1 and 4 eliminate that state before the behavior exists, so the evidence +retired itself. The surviving evidence is the routine case: one omitted new plugin produces three simultaneous gaps (its +absence from two listings and one version record), which is exactly the "a new plugin is added" flow. + +**Evidence.** provided — the source artifact: "If something is missing, it stops and says so instead of shipping around +the gap." codebase — `.claude/skills/han-release/SKILL.md` already hard-stops rather than warns for unsafe +preconditions: "Releasing an unknown working state is unsafe and a pushed tag is hard to reverse. This is a hard stop, +not a pause gate." codebase — a plugin omitted from both storefront listings and its channel-two version record produces +three gaps at once, so the multi-gap case is the routine one, not a relic of today's state. + +**Rejected alternatives.** + +- _Warn and continue._ Rejected: reproduces the current failure with a log line. The evidence that warnings do not work + here is the twenty releases that went by. +- _Stop on the first gap._ Rejected: makes the maintainer rediscover the set one release attempt at a time, when the + process already knows all of them. + +**Driven by findings:** F2, F15 +**Linked technical notes:** — +**Dependent decisions:** D24 +**Referenced in spec:** Primary flow (Step 3), Alternate flows and states, User interactions + +### D13: Both untrue declarations are deleted rather than made true + +**Outcome.** The reporting plugin's and the feedback plugin's declarations on the core plugin are deleted. Neither is +made true by introducing a use. + +**Rationale.** Neither plugin uses the core plugin, and neither wants to. The reporting plugin's use genuinely moved to +the communication plugin and the declaration is a leftover. The feedback plugin cannot invoke another plugin even in +principle, so no edit short of changing what it is permitted to do could make its declaration true. Deleting is the change +that makes the declaration match reality; anything else changes reality to match a leftover. + +**Evidence.** codebase — `grep -rn "han-core" han-reporting/` outside its manifest returns nothing; its skills reference +only `han-communication:readability-guidance` and `han-communication:readability-editor`. codebase — +`han-feedback/skills/han-feedback/SKILL.md:9` declares +`allowed-tools: Read, Write, Bash(ls *), Bash(mkdir *), Bash(gh *), Bash(date *)` — no `Agent` and no `Skill`, so it +structurally cannot invoke `han-core`. Its every "han-core" mention is a string literal it writes into a report. provided +— the source artifact: "it is not permitted to call other plugins at all, so its claim cannot possibly be true." + +**Rejected alternatives.** + +- _Leave them; the cost is only a needless install._ Rejected: the cost is trust. Once two declarations are decorative, + none of them answers "what actually breaks if I change this?" +- _Give the feedback plugin the ability to invoke the core plugin so its declaration becomes true._ Rejected: inventing a + use to justify a leftover. + +**Driven by findings:** — +**Linked technical notes:** — +**Dependent decisions:** D7, D18 +**Referenced in spec:** Primary flow (Step 5) + +### D14: The release runs the check rather than restating it + +**Outcome.** The release holds no copy of the rule. It runs the check and reports what the check says. The bundle +exception is stated once, where the rule lives. + +**Rationale.** Rewritten after review. The original entry said the two "must answer it identically" — a wish with no +bearer. Nothing enforced it and nothing would detect when it stopped being true. + +This matters more than ordinary duplication, because the root cause at D5 *is* an unverified prose instruction that told +an agent to read the wrong file for twenty releases. Rewriting that prose to read four files instead of two leaves the +same defect class in place, now with more files to forget, and puts the bundle exception in two places — a second copy of +a rule whose entire purpose is to not be ignorable. + +This also settles when the rule exists: from the release repair onward, because the release runs it. The last step makes +it blocking, not existent (see D1). + +**Evidence.** provided — the source artifact's "After" diagram routes the release through the same gate ("Does every +plugin appear everywhere it should?") that the check enforces. codebase — `.claude/skills/han-release/SKILL.md` is prose +instructions to an agent while the check is executable, so a shared bearer is a real design constraint rather than a +refactoring preference. codebase — the same skill's Step 4.2 ("Sync that plugin's `marketplace.json` entry… Select by +name, not by index") is an existing hand-sync of exactly the class that drifted, which is what a prose restatement would +reproduce. + +**Rejected alternatives.** + +- _Implement the rule separately in each and keep them in sync by care._ Rejected: two rules to keep in sync is the same + defect class as the two version lines at D10 and the duplicated rule files noted as out of scope. Care is what failed + for twenty releases. +- _Assert "they must answer identically" and leave the bearer unspecified._ Rejected: that was the original entry, and it + committed to nothing. + +**Driven by findings:** F3 +**Linked technical notes:** — +**Dependent decisions:** D1, D24 +**Referenced in spec:** Primary flow (Steps 3 and 7), Coordinations + +### D17: The foreign-annotation category exists at every layer that inspects a heading + +**Outcome.** Step 2 changes both the publisher's script and the publisher's prose repair pass. A foreign annotation is a +distinct category at each, and the repair pass must not silently repair it. + +**Rationale.** Promoted from trivial to full after review, because the original entry was wrong in a way that would have +shipped a fix that does not fix the documented workflow. + +The original said step 2 "changes nothing about what any publisher writes… which keeps the blast radius inside the GitHub +publisher". True but insufficient. The real user path runs the skill's prose-driven repair pass *before* the script ever +executes. That pass recognizes exactly two valid heading shapes and would bucket a foreign annotation as a generic +"Malformed heading", then route it to "propose the corrected shape based on the surrounding text" — potentially stripping +or reformatting the annotation and applying the fill before the gate sees it. The gate would then protect only a direct +script invocation, not the way anyone actually runs it. + +**Evidence.** codebase — `han-github/skills/work-items-to-issues/SKILL.md:65-115` runs an evidence-based repair pass at +Step 3, before the publish pipeline at Step 6. codebase — the same file's `:71-72` names only two valid shapes (bare and +GitHub-annotated), so a Jira- or Linear-annotated heading matches neither. codebase — the same file's `:89` lists +"Malformed heading — propose the corrected shape based on the surrounding text" as the handling for anything unmatched. + +**Rejected alternatives.** + +- _Change only the script._ Rejected: protects standalone invocation and leaves the documented workflow unprotected, + which is the majority path. +- _Change only the repair pass._ Rejected: leaves the script silently lossy when invoked directly, which its own usage + header invites. + +**Driven by findings:** F7 +**Linked technical notes:** — +**Dependent decisions:** D30 +**Referenced in spec:** Primary flow (Step 2) + +### D18: The execution order is a partial order, and the repair precedes the correction + +**Outcome.** The steps execute as 1, 2, 6, 3, 4, 5, 7 in the source plan's numbering — that is: publish the Linear +plugin, close the work-items hole, repair the release, correct the versions, delete the declarations, correct the +documents, turn on the check. The binding constraints are named; the rest are free. + +**Rationale.** Two problems with the original total order. + +First, it was not a total order. The source plan's ordering argument is entirely about the check (D1), which forces one +adjacency. Presenting six others as equally load-bearing made the one that genuinely bites invisible — rigidity +everywhere is rigidity nowhere. + +Second, and materially: correcting the versions before repairing the release opens a window in which any release +re-freezes the correction. The plan's own work triggers it, because deleting the declarations edits two plugin +directories, which forces both plugins to bump at the next release. The check would then land red — the exact outcome D1 +exists to prevent. Moving the repair ahead of the correction closes the window structurally rather than by asking a +maintainer to promise not to release. + +The real constraints: the check is last; the release repair precedes the version correction; the declaration deletion and +the document correction ship as one unit (the intermediate state is what the document correction exists to prevent); the +work-items fix is independent of everything. + +**Evidence.** codebase — `.claude/skills/han-release/SKILL.md` contains zero references to `.codex-plugin` or +`.agents/plugins`, so it provably writes only channel one until repaired. codebase — `docs/semantic-versioning.md`'s "A +child bumps only when its own directory changed in `{range}`" means editing `han-reporting/` and `han-feedback/` forces +both to bump at the next release. provided — the user chose the reorder over the alternative after both were surfaced +with their trade-offs, consciously overriding the source plan's listing order. + +**Rejected alternatives.** + +- _Keep the source's listing order and add a precondition that no release is cut in the window._ Rejected by the user + after being recommended: it preserves the source order and closes the window with one sentence, but it relies on a + maintainer honoring a promise with nothing enforcing it. The reorder makes the window impossible instead of forbidden. +- _Keep the order and fold the version correction into the release repair._ Rejected: blurs what each step owns for no + gain over the reorder. +- _Present all seven as a chain, as the source plan does._ Rejected: five of the six adjacencies have no dependency + behind them, and asserting them hid the one that did. + +**Driven by findings:** F1 +**Linked technical notes:** — +**Dependent decisions:** — +**Referenced in spec:** header, Primary flow (intro, Steps 3 and 4, and the source-position annotations throughout), +Coordinations + +### D19: A plugin, and the targets it belongs in, are defined positively + +**Outcome.** A plugin is a directory the suite ships as an installable unit. Every plugin belongs in all four targets, +except the bundle, which belongs only in channel one's two. Detection is the implementation plan's business. + +**Rationale.** The rule was previously defined only by subtraction from its exception, and "plugin" was not defined at +all. Two edge-case rows depended on the missing definition, and D14 asks two consumers to answer one question +identically — an ambiguous question cannot have one answer. + +The definition has to avoid one specific trap: "has skills" cannot be part of it, because the bundle has a manifest and +no skills at all, permanently and legitimately. And if the rule were "has a channel-one manifest", a plugin published +only to channel two would be invisible — today's bug mirrored onto the other channel. + +**Evidence.** codebase — `han/.claude-plugin/plugin.json` exists with no `skills/` directory, and `CLAUDE.md` describes +the bundle as having "no components of its own", so zero-skill is a permanent legitimate state. codebase — no stray +`plugin.json` exists outside the ten real plugin directories today (`han-plugin-builder`'s templates are named +`plugin-example.json`, avoiding collision), so no false positive exists to design around. codebase — every current +listing entry resolves to a real directory. + +**Rejected alternatives.** + +- _Define a plugin as a directory with a channel-one manifest._ Rejected: makes a channel-two-only plugin invisible, + which is the current bug mirrored. +- _Define a plugin as a directory with skills._ Rejected: the bundle has none and is permanent. +- _Leave "belongs in" defined by its exception._ Rejected: stating a rule only by its exception leaves the next exception + nowhere to attach. + +**Driven by findings:** F5, F26 +**Linked technical notes:** — +**Dependent decisions:** D20, D29 +**Referenced in spec:** Channels and targets, Edge cases + +### D20: Version agreement covers every record that publishes a version + +**Outcome.** The version-agreement rule covers every record that publishes a version — there are three, not two. Listing +membership covers both storefronts. Channel two's storefront listing carries no version, so the rule does not reach it. + +**Rationale.** Correcting a factual error. The spec said the release updates "two version records and two storefront +listings", which is wrong: channel one's storefront listing also carries a version per plugin, so one target is both a +listing and a version record. The four targets do not partition into two and two. + +This matters beyond tidiness. Channel one's listing-versus-manifest version pair is hand-synced by the release today and +nothing verifies it — a hand-sync of exactly the class that drifted on channel two. Stating "two version records" would +have left it outside the check. + +Worth naming the mechanism: the spec's channel-neutral vocabulary is correct and should stay, but the euphemism is what +let a miscount survive drafting. The remedy is a more accurate abstraction, not file paths in the spec. + +**Evidence.** codebase — `.claude-plugin/marketplace.json` carries a `version` field per plugin for all eleven entries +and is also the channel-one storefront listing. codebase — `.agents/plugins/marketplace.json` carries no `version` field +for any of its nine entries. codebase — `.claude/skills/han-release/SKILL.md` Step 4.2 hand-syncs the channel-one +manifest version to the channel-one listing entry, unverified. + +**Rejected alternatives.** + +- _Keep "two version records and two listings."_ Rejected: factually false, and it leaves the channel-one hand-sync + outside the rule. +- _Name the four files in the spec._ Rejected: that is what the decision log and the Channels-and-targets glossary are + for. + +**Driven by findings:** F4 +**Linked technical notes:** — +**Dependent decisions:** — +**Referenced in spec:** Channels and targets, Edge cases + +### D21: The release-procedure documents are owned by the step that falsifies them + +**Outcome.** The documents describing the release procedure are corrected by the release-repair step, not by the +document-correction step. The Outcome carries a distinct bullet for them. + +**Rationale.** Review found that the release repair and the check falsify two more documents, and the +document-correction step's charter did not cover them. The instinct was to widen that charter to "topology or release +procedure". The better fix is narrower: the gap was in the **Outcome**, which had exactly one documentation bullet +(topology) and none for the procedure. That is why those documents had no home. + +Widening the document-correction step would make it a grab-bag of two unrelated jobs, sequenced against the declaration +deletion when half of it depends on the release repair instead. Under D7's rule — a document is in scope when this work +falsifies it — the falsifying step owns it. The release repair falsifies them, so it owns them. + +This is load-bearing for the spec's own promise. The "a new plugin is added → the check fails on the pull request" flow +is only defensible behavior if the contributor guide told them the four places. Today it tells them one. + +**Evidence.** codebase — `CONTRIBUTING.md:157` instructs "Update the marketplace registry at +[`.claude-plugin/marketplace.json`]… if the new skill ships in a different plugin's component set" — one of four targets. +codebase — `docs/semantic-versioning.md:4`, `:92`, and `:172` each instruct syncing the version to "the plugin's entry in +`marketplace.json`", singular, and the file contains **zero** occurrences of "codex". codebase — +`docs/how-to/extend-han-with-plugin-dependencies.md:150` teaches "The plugins are all listed in one `marketplace.json`", +which the repair falsifies; it sits in the same paragraph block the document-correction step is already rewriting, so the +two steps coordinate there. + +**Rejected alternatives.** + +- _Widen the document-correction step to cover both._ Rejected: two unrelated jobs in one step, sequenced against the + wrong dependency. +- _Leave them; they are about procedure, not topology._ Rejected: the check blocks a contributor for following the guide + the project gave them, which is a worse broken promise than the one at D11. + +**Driven by findings:** F8 +**Linked technical notes:** — +**Dependent decisions:** — +**Referenced in spec:** Outcome, Primary flow (Step 3 owns the documents; Step 6 points at that ownership), +Alternate flows and states + +### D22: The new version record is created at the plugin's channel-one version + +**Outcome.** Step 1 creates the Linear plugin's channel-two version record at the version channel one already publishes +for it. + +**Rationale.** Step 1 must choose a version and the spec did not say which. The choice determines whether a step 1 to +step 4 dependency exists at all: create it at the channel-one version and the correction step never touches it; create it +at some "matching the other codex manifests" value and step 1 has deliberately introduced a defect for a later step to +repair. This follows directly from D10, and it dissolves the coupling rather than managing it. + +**Evidence.** codebase — `han-linear/.claude-plugin/plugin.json` is at `1.0.2`. codebase — every other plugin's +`.codex-plugin/plugin.json` was created at some `1.x` unrelated to its channel-one number, which is the pattern that +produced the drift D10 fixes; repeating it here would manufacture a ninth instance. + +**Rejected alternatives.** + +- _Create it at 1.0.0 to match the other codex manifests._ Rejected: manufactures a defect for step 4 to repair, and + perpetuates the pattern D10 exists to end. +- _Leave it unstated._ Rejected: it is the single most concrete inter-step coupling in the plan. + +**Driven by findings:** F13 +**Linked technical notes:** — +**Dependent decisions:** — +**Referenced in spec:** Primary flow (Step 1) + +### D23: Step 2 is a distinct concern retained by instruction + +**Outcome.** Step 2 stays in this specification. The spec records that it shares no actor, artifact, target, or failure +mode with the other six steps, and that it is independently schedulable. + +**Rationale.** Review established that "publish" means two different things across this plan: Han-the-product being +published to marketplaces, and a user's work-items file being published to an issue tracker. Step 2 belongs to the +second and everything else to the first. The check does not check it; the release does not touch it; no decision argued +for its inclusion, and D15 argues the other way by naming the folder for the publishing pipeline. + +The honest justification is that the user asked for all seven steps in order. That is a legitimate reason to keep it and +not a reason to pretend the coupling is real. Recording the seam costs a sentence and keeps the spec truthful about its +own shape. + +**Evidence.** provided — the user's instruction to include all seven steps, reaffirmed after the seam was surfaced with +the alternative of splitting it out. codebase — step 2 touches only `han-github/skills/work-items-to-issues/`, which no +other step touches. + +**Rejected alternatives.** + +- _Split step 2 into its own specification._ Rejected by the user after being offered: it would make the remaining six a + single coherent feature with one actor, but it departs from the instruction to include all seven. +- _Argue the coupling is real ("both are Han losing track of something it published")._ Rejected outright: no evidence + supports it beyond shared provenance, and manufacturing a coupling to justify a grouping is exactly the reasoning this + spec's YAGNI rule exists to catch. + +**Driven by findings:** F12 +**Linked technical notes:** — +**Dependent decisions:** — +**Referenced in spec:** Primary flow (Step 2) + +### D24: The gate runs after all targets are written and before anything irreversible + +**Outcome.** One gate. It runs after the release has written all four targets and before it commits, tags, pushes, or +publishes. + +**Rationale.** The original spec said the release "stops before publishing anything". In the release skill's own +vocabulary "publish" names its final step, so that sentence permitted stopping *after* the tag was pushed — the +irreversible act D12's rationale says it exists to prevent. + +The placement is forced from both sides. It cannot run earlier than the writes: at release start the versions agree, and +the release itself is what breaks the agreement when it writes the new channel-one version, so a pre-flight gate passes +trivially and says nothing about the state being released. It cannot run later than the commit: everything after is hard +to reverse. Between the writes and the commit is the only window where the gate can see the real state and still fail +harmlessly. + +This also makes partial failure a non-event: every write precedes anything irreversible, so a failure mid-write leaves +local modifications and nothing published. Recovery is discarding them. No compensation or rollback machinery is needed, +and specifying any would be building for an incident that cannot happen. + +And it settles what stops a release cut from a branch where a step has not landed: the gate, not a pull-request check +that may never have run. + +**Evidence.** codebase — `.claude/skills/han-release/SKILL.md` Step 9 is titled "Publish the GitHub release", so +"publish" is a defined term naming the last step. codebase — its irreversible actions are Step 8.3 (`git push origin +v{parent target}`) and Step 9 (`gh release create`); all target writes happen at Step 4. codebase — the same skill's +Step 1.2 sets the precedent: "This is a hard stop, not a pause gate", reasoned from "a pushed tag is hard to reverse". +codebase — Steps 8.2 and 9 are already re-entrant ("do **not** recreate it"; "do not create a second one"), so a stopped +release is safely re-runnable. codebase — Step 1.3 explicitly permits cutting from a non-default branch ("do not +stop… surface the fact, do not block"), and `.github/workflows/ci.yml` triggers only on `pull_request` and pushes to +`main`, so a branch with no pull request gets no pipeline run. + +**Rejected alternatives.** + +- _A pre-flight gate before the release does its work._ Rejected: cannot see the version agreement it is meant to check, + because the release has not written the new version yet. Offered in review as an ergonomic addition; the reviewer + themselves marked it as not a correctness concern and said not to add it here. +- _Gate after the tag, before the GitHub release._ Rejected: the tag is the hard-to-reverse thing. +- _Two gates, one for membership and one for versions._ Rejected: two placements, two things to keep aligned, no benefit + over one gate placed where both questions are answerable. + +**Driven by findings:** F2, F11, F23 +**Linked technical notes:** — +**Dependent decisions:** — +**Referenced in spec:** Primary flow (Step 3), Alternate flows and states, Coordinations + +### D25: Manifest descriptions are documents; the declarations are the record + +**Outcome.** A plugin manifest's description field is a document and is in scope for D7's rule. A manifest's dependency +declarations are the record itself, not a description of it, and are the deletion step's business. + +**Rationale.** The spec said "no document describes a dependency the suite does not have" without saying whether +manifests count. Description fields are user-facing prose rendered in the storefront, so they can be false in exactly the +way the rule cares about. The sweep came back clean, so this costs nothing today — but the next person applying the rule +needs to know whether manifests are in it, and "we checked and it was fine" is a real result worth recording. + +**Evidence.** codebase — `han-reporting/.claude-plugin/plugin.json` and `han-feedback/.claude-plugin/plugin.json` +description fields do not narrate dependencies. codebase — `.claude-plugin/marketplace.json`'s description for the bundle +narrates only the bundle's own dependencies, which this work does not change, so it stays true. codebase — +`.agents/plugins/marketplace.json` carries no dependency narration at all. + +**Rejected alternatives.** + +- _Leave it unstated because the sweep was clean._ Rejected: the rule outlives this sweep and the next reader needs its + boundary. + +**Driven by findings:** F27 +**Linked technical notes:** — +**Dependent decisions:** — +**Referenced in spec:** Primary flow (Step 6) + +### D26: Corrected documents state the rule and point at the record + +**Outcome.** The corrected documents state the dependency rule and point at the manifests as the record, rather than +restating manifest contents in prose. Hardcoded counts in the affected passages go with them. + +**Rationale.** The contributor-guide claim in scope fails as a universal quantifier that outlived its truth. The obvious +fix — swap it for an enumeration of the plugins that do depend on core — has the same half-life: it is false the day +someone adds a plugin, and nothing in the repository will notice. That is the standing-defect shape D5 diagnoses in the +release process, relocated into prose. Fixing a stale claim with a differently-stale claim is not a fix. + +The repository already models the alternative and already has the convention. + +**Evidence.** codebase — `CLAUDE.md` § Conventions: "**Indexes stay complete, not counted.** …Verify the indexes list +every entity when editing them, rather than tracking a running total." codebase — `docs/concepts.md:221` ("Each of these +**four** depends on `han-core`") and `:229` ("Each of these **three** depends on `han-core`") are hardcoded counts in the +exact passages the correction must edit. codebase — `docs/concepts.md:238` already models the target pattern ("For which +one to install and the dependency that surprises people, read [Choosing a Han plugin]"): one canonical location, +everything else pointing at it. + +**Rejected alternatives.** + +- _Swap the universal claim for an enumeration._ Rejected: same defect, longer fuse. +- _Also de-duplicate the topology narration in the agent-facing project map, which states it three times in one file._ + Rejected: real, but not falsified by this work and therefore outside D7's rule. The correction should not deepen it + either. + +**Driven by findings:** F10 +**Linked technical notes:** — +**Dependent decisions:** — +**Referenced in spec:** Primary flow (Step 6) + +### D27: The tutorial teaches shape and stops promising real version numbers + +**Outcome.** The tutorial's claim to print the real on-disk version numbers is deleted. Its existing "describing the +shape, not pinning the numbers" caveat covers the document, including the child plugins' dependency arrays. + +**Rationale.** The claim is already false — every version number it names is wrong — and the repointing at D8 would +otherwise rewrite the plugin names in that very paragraph while leaving the promise standing. An untouched lie is a bug; +a lie a maintainer edited around and left is a signal the doc is unmaintained. + +The document already argues for this answer against itself. The same sentence that makes the promise immediately +withdraws it: "If you are reading the manifests and the numbers differ, the manifests are right; this guide is describing +the shape, not pinning the numbers." Deleting the first half and keeping the second is the edit the document is already +asking for. + +**Evidence.** codebase — `docs/how-to/extend-han-with-plugin-dependencies.md:212-214` claims `han-core`, `han-github`, +`han-reporting`, and `han-feedback` "are at 1.0.0 and `han` is at 3.0.0 as written"; on disk they are 2.2.1, 2.2.2, +2.1.1, 2.0.0, and 4.6.0. All five are wrong. codebase — the same sentence carries its own escape hatch, quoted above. +codebase — the tutorial's `:114-118` prints the reporting plugin's manifest as `["han-core"]` when it is really +`["han-communication", "han-core"]`, so the printed manifests are already inexact in a second, unrelated direction, and +substituting the coding plugin reproduces it. codebase — `:84-86` already carves out a "simplified example" caveat for +the core plugin's own dependency, establishing the pattern. + +**Rejected alternatives.** + +- _Keep the promise and print the real manifests, syncing every release._ Rejected under YAGNI: no evidence any reader + wanted the numbers real, and it creates a per-release maintenance obligation nobody asked for. The reviewer who raised + this flagged the option as a YAGNI candidate themselves. +- _Leave the promise; it is not what this work falsified._ Rejected: D7's rule excludes documents this work does not + falsify, but D8 rewrites this exact paragraph. Editing around a known falsehood in the sentence you are already + changing is the D9 situation again. + +**Driven by findings:** F9, F19 +**Linked technical notes:** — +**Dependent decisions:** — +**Referenced in spec:** Primary flow (Step 6) + +### D28: The check ships with no disable switch + +**Outcome.** No disable mechanism, no configuration to skip it, no bypass flag. Recorded as a deliberate non-decision so +the implementation plan does not add one. + +**Rationale.** A disable mechanism is the "land it disabled" alternative D1 already rejects, and it is the same shape as +the configurable exception mechanism D6 rejects. Reversibility already exists: the check lands in one commit, so +reverting that commit is the escape hatch. D18's ordering is engineered so the check is green on arrival, which is the +real mitigation. + +Building a bypass "for safety" before the check has ever run once is a feature flag with no kill-switch criteria and no +owner. + +**Evidence.** codebase — the check does not exist yet, so no false positive exists to design around. codebase — every +other hook in `.pre-commit-config.yaml` ships without a bypass. provided — D1's own rationale, which establishes that the +failure mode is people turning checks off, not checks being too strict. + +**Rejected alternatives.** + +- _Add a documented bypass for emergencies._ Rejected under YAGNI: no incident, no evidence, and it recreates the + disabled-check failure D1 is built around. **Reopening trigger:** the check produces its first false positive on a + pull request that is actually correct. + +**Driven by findings:** F24 +**Linked technical notes:** — +**Dependent decisions:** — +**Referenced in spec:** Primary flow (Step 7), Deferred (YAGNI) + +### D29: A listing entry with no plugin behind it fails the check + +**Outcome.** The check fails when a storefront listing names a plugin that does not exist in the repository, not only +when the repository holds a plugin no listing names. + +**Rationale.** Promoted from an uncited spec table row after review. The row was the only one in the table with no +decision behind it, and its entire justification was "a listing entry pointing at nothing is the same class of defect" — +which is the symmetry anti-pattern verbatim, and would have failed the evidence test as written. + +The behavior is right and real evidence exists; it simply was not cited. A listing entry resolving to nothing breaks the +Outcome's own first promise directly: the documented install command errors, which is precisely the D11 failure this work +starts by fixing. + +**Evidence.** codebase — both storefront listings reference plugins by relative path +(`"source": "./han-core"`-style), so a moved or removed directory leaves the entry dangling. codebase — this repository +has already renamed every plugin directory in a single commit (`d94daa2`, "Rename all plugins to use hyphens instead of +dots"), which is mechanically identical to delete-old-path-add-new; that commit updated the listings atomically, but it +establishes that the repository does move plugin directories. codebase — every listing entry resolves today, so this is +prevention of a demonstrated-possible edit, not a fix for a live break. + +**Rejected alternatives.** + +- _Defer it; nothing is broken today._ Rejected: it is the same set comparison as the direction already being built, the + rename precedent is real, and the failure it prevents is the Outcome's headline promise. +- _Keep it justified by "same class of defect."_ Rejected: that is the reasoning the spec's own YAGNI rule names as an + anti-pattern. The behavior survives; the justification did not. + +**Driven by findings:** F16 +**Linked technical notes:** — +**Dependent decisions:** — +**Referenced in spec:** Edge cases + +### D30: "Accounted for" is defined so the promise is not circular + +**Outcome.** Every heading in a work-items file is either published, skipped-and-counted, or surfaced. "Accounted for" +means one of those three happened to it — not "it matched a pattern we recognize". + +**Rationale.** The original promise was circular. A work item was defined as a heading matching a recognized pattern, so +"every work item is accounted for" reduced to "every item we recognize is accounted for", which was already true. The +foreign-annotation fix would have closed one way of not matching and left every other way silent. + +The other ways are not hypothetical. The separator is an em-dash, which needs a special input method; most editors and +copy-paste produce a plain hyphen. A hand-edited file with `## W-1 - title` fails every pattern and vanishes exactly like +a foreign annotation does. The publisher's own skill already lists "Malformed heading" as an expected finding, so its +author expected this. + +Defining the promise by outcome rather than by pattern is also what makes the user-facing commitment achievable: a person +can only tell "already published to another tracker" from "file is malformed" if malformed is a detectable category +rather than a residue. + +**Evidence.** codebase — `han-github/skills/work-items-to-issues/references/work-items-file-format.md:55` defines a slice +as "one slice per `## ` heading", which is the circularity. codebase — `create-issues.sh:60`, `:72`, +`:95`, `:102` all anchor on the literal em-dash (U+2014) with a space either side. codebase — +`han-github/skills/work-items-to-issues/SKILL.md:89` already lists "Malformed heading — propose the corrected shape based +on the surrounding text" as a Step 3 validation finding, so malformed headings are an expected category one layer up. + +**Rejected alternatives.** + +- _Promise only that foreign annotations are surfaced._ Rejected: narrower than the step's stated outcome and leaves the + hyphen case silent, which is the most likely malformation in practice. +- _Enumerate every malformation to detect._ Rejected: the outcome-shaped definition covers them without a list that will + be incomplete. + +**Driven by findings:** F7 +**Linked technical notes:** — +**Dependent decisions:** — +**Referenced in spec:** Primary flow (Step 2), Edge cases, User interactions + +## Trivial decisions + +- **D15: Output location** — `docs/plans/han-publishing-cleanup/`, alongside the existing plan folders. Named for the + publishing pipeline because that is the actual problem, not the dependency graph (considered `docs/plans/han-cleanup/` + to match the source artifact's filename; rejected because it names the investigation rather than the work). — + Referenced in spec: (header). +- **D16: Channel one is left alone** — it is healthy, and every change here brings channel two up to it. — Referenced in + spec: Out of scope. diff --git a/docs/plans/han-publishing-cleanup/artifacts/feature-technical-notes.md b/docs/plans/han-publishing-cleanup/artifacts/feature-technical-notes.md new file mode 100644 index 00000000..2451a577 --- /dev/null +++ b/docs/plans/han-publishing-cleanup/artifacts/feature-technical-notes.md @@ -0,0 +1,59 @@ +# Technical Notes: Han Publishing Cleanup + +Spec: [../feature-specification.md](../feature-specification.md) · Decisions: [decision-log.md](decision-log.md) · +Findings: [team-findings.md](team-findings.md) + +Load-bearing mechanics captured because a behavioral commitment in the spec is only correct because of them. Mechanics +discoverable from this repository are cited as `Evidence:` on their decision instead and do not appear here. + +## T1: Update availability on channel two is decided by the published version number + +**Context.** The spec commits to a user-visible behavior in its Outcome and in step 4: people on channel two are offered +updates again. That claim is only correct because of the mechanic below. Without it, frozen version numbers would be a +cosmetic labelling problem — the plugin contents on that channel would still refresh, and step 4 would be tidying rather +than repairing. + +**Technical detail.** Channel two's client decides whether an update is available for an installed plugin by comparing +the version the channel publishes for that plugin against the version the user has installed. It does not compare file +contents. Because the published versions have not moved since the day they were written, the comparison always reports +"no update available", so the client never offers one. Users are running old skills with no signal that newer ones exist. +This is why the defect is silent from the user's side as well as from the release's side, and why correcting the numbers +is sufficient to restore update prompts without any other change to that channel. + +**Why this is not discoverable from the repository.** The comparison happens inside channel two's client, which is not +part of this codebase. The repository shows only the published version records, from which the frozen-ness is visible but +the consequence is not. + +**Evidence quality.** provided, single-source, with codebase corroboration by analogy — upgraded during review. + +- **provided (single-source).** The source artifact asserts it: "That channel decides whether an update is available by + reading a version number that has not moved since the day it was written. So it never offers anyone an update." +- **codebase (corroborates the mechanism class, not this channel).** `docs/semantic-versioning.md:4` describes the + version field as what a channel uses "to detect that updates are available". That establishes version-comparison as + how update detection works in this suite's publishing model — but it is written about channel one, so it does not + confirm channel two behaves the same way. +- **codebase (corroborates that the two clients differ).** `README.md:74` records that channel two "resolves no + dependencies", establishing that its client has materially different resolution behavior from channel one's. That cuts + both ways: it proves the clients are not interchangeable, which is exactly why the channel-one evidence above cannot + simply be transferred. + +The net position: the mechanism is the obvious one and is documented for the sibling channel, but **no source confirms it +for channel two specifically**. This remains a claim driving a user-facing commitment on weaker evidence than the +commitment implies, and it is flagged as such per +[evidence-rule.md](../../../../han-planning/references/evidence-rule.md). + +**If this note is wrong.** Step 4 is still worth doing — the numbers are wrong and D10 wants one version per plugin — but +its stated user outcome is overclaimed, and the spec's Outcome must lose the update-prompt sentence. The blast radius is +narrow and entirely confined to that one claim; no other decision depends on this note. + +**Verification.** One installed client on channel two, one release, one look at whether an update is offered. This is +[Open item 1](../feature-specification.md#open-items), owned by the maintainer, and it is the only open item in the spec +with a named cost and a named consequence. It is worth doing before the spec's Outcome is quoted to anyone. + +Note that [Open item 2](../feature-specification.md#open-items) compounds this: if channel two's client resolves from +the latest release tag rather than the default branch, then the verification itself must wait for a release, and step 4's +outcome reaches users then rather than on merge. + +**Supports decisions:** D10 +**Driven by findings:** F6 +**Referenced in spec:** Outcome, Primary flow (Step 4), Open items diff --git a/docs/plans/han-publishing-cleanup/artifacts/team-findings.md b/docs/plans/han-publishing-cleanup/artifacts/team-findings.md new file mode 100644 index 00000000..7dbeac9f --- /dev/null +++ b/docs/plans/han-publishing-cleanup/artifacts/team-findings.md @@ -0,0 +1,405 @@ +# Team Findings: Han Publishing Cleanup + +Spec: [../feature-specification.md](../feature-specification.md) · Decisions: [decision-log.md](decision-log.md) · +Technical notes: [feature-technical-notes.md](feature-technical-notes.md) + +Feature size: **Medium** — three subsystems (the release pipeline, the work-items publishers, the documentation set), +touches rollout and pipeline gating, no authentication or personal-data surface, no data migration once annotation +namespacing is scoped out (D2). + +Review team (4, at the medium cap): + +- `han-core:junior-developer` — hidden assumptions, muddied scope, unstated prerequisites. +- `han-core:devops-engineer` — the release pipeline, the gate, rollout ordering, the check's placement. +- `han-core:edge-case-explorer` — the work-items annotation boundary cases and the check's own edges. +- `han-core:information-architect` — the documentation set's correctness and the tutorial's worked example. + +Mechanic-focused specialists (structural, behavioral, concurrency, software-architect, system-architect) were +deliberately excluded per the skill's default spec-stage roster. + +Every specialist claim cited below was independently verified against the files before the finding was accepted. Three +were verified and **rejected** (F19, F20, F21). + +## Major findings + +### F1: The execution order is a partial order, and one unforced adjacency hid a real hole + +**Raised by:** `junior-developer` (JD-001, JD-003) and `devops-engineer` (DOR-004), independently and convergently. + +**Finding.** The spec presented all seven steps as one non-negotiable chain. The source plan's argument for the order is +entirely about the check ("a check that blocks everything on day one gets disabled"), which supports exactly one +constraint: everything before the check. Presenting six other adjacencies as equally load-bearing made the one adjacency +that genuinely bites invisible. + +That adjacency: the version correction (source step 3) fixed channel two's versions, but the release repair (source +step 6) is what teaches the release to *write* channel two. Any release cut between them re-freezes what the correction +just fixed, and the check then lands red — the exact outcome the ordering exists to prevent. The plan's own work is the +trigger: deleting the declarations edits two plugin directories, which forces both to bump at the next release. + +**Verified.** The release skill contains zero references to the codex manifests or the channel-two listing, so it +provably writes only channel one. The versioning doc's "a child bumps only when its own directory changed" confirms the +declaration deletion forces a bump. + +**Resolved by:** user input. Two options were surfaced: state a precondition that no release is cut in the window +(preserves the source's listing order, relies on a promise), or reorder so the repair precedes the correction (closes +the window structurally). The user chose the reorder, consciously overriding the source's listing order. + +**Affected decisions:** D18 (new), D1 (rescoped to the check only), D11 (unchanged — step 1 still first) +**Affected tech-notes:** — +**Changed in spec:** Primary flow (reordered and re-annotated with source positions), Summary + +### F2: The release gate had no placement, and the natural reading put it after the tag + +**Raised by:** `devops-engineer` (DOR-001, DOR-002). + +**Finding.** The spec said the release "stops before publishing anything". In the release skill's own vocabulary, +"publish" names its final step — so the sentence permitted stopping *after* the tag was already pushed, which is the +irreversible act the decision was meant to avoid. Worse, a pre-flight gate cannot check version agreement at all: at +release start the versions agree, and the release itself is what breaks the agreement when it writes the new channel-one +version. A gate that runs before the writes passes trivially and says nothing about the state being released. + +**Verified.** The release skill's irreversible actions are the tag push and the release creation; all target writes +happen earlier, and the skill already hard-stops on a dirty working tree with the reasoning "a pushed tag is hard to +reverse". + +**Resolved by:** evidence. One gate, placed after all four targets are written and before the commit — late enough to +judge the state actually being released, early enough that everything after it is local and reversible. The +devops-engineer's suggested pre-flight membership check was **not** adopted: it is ergonomics, not correctness, and the +single gate satisfies both halves of the rule. + +**Affected decisions:** D24 (new), D12 (evidence corrected — see F15) +**Affected tech-notes:** — +**Changed in spec:** Primary flow (Step 3), Alternate flows and states, Edge cases + +### F3: "Share one answer" was a wish with no bearer + +**Raised by:** `devops-engineer` (DOR-003) and `junior-developer` (JD-007), convergently. + +**Finding.** The spec said the check and the release "must answer it identically". Nothing enforced it and nothing would +detect when it stopped being true. This matters more than ordinary duplication: the root cause being fixed *is* an +unverified prose instruction telling an agent to read the wrong file for twenty releases. Rewriting that prose to read +four files instead of two leaves the same defect class in place, now with more files to forget, and puts the bundle +exception in two places. + +The junior-developer added the sharper form: if the rule is shared, it must exist when the release repair lands — one +step before "only now does the check land". So either the rule lands early and the last step is just making it blocking, +or the rule is written twice and the decision is violated by the plan's own sequence. + +**Resolved by:** evidence. The release holds no copy of the rule; it runs the check and reports what the check says. The +rule therefore lands with the release repair, and the last step makes it *blocking* rather than bringing it into +existence. The spec now says this explicitly. + +**Affected decisions:** D14 (rewritten), D1 (rescoped: "the check lands last" is about enforcement, not existence) +**Affected tech-notes:** — +**Changed in spec:** Primary flow (Steps 3 and 7), Coordinations + +### F4: The spec mis-stated the number of version records + +**Raised by:** `devops-engineer` (DOR-005) and `information-architect` (F11), convergently. + +**Finding.** The spec said the release updates "two version records and two storefront listings". Verified false on +disk: channel one's storefront listing *also* carries a version per plugin, so there are **three** version records, not +two. And channel two's storefront listing carries no version field at all, so the version-agreement rule has no meaning +for it. The four targets do not partition into "two and two"; one target is both a listing and a version record. + +The mechanism is worth naming: the spec's channel-neutral vocabulary is correct and disciplined, but the euphemism is +what let a factual miscount survive drafting. The remedy is a more accurate abstraction, not file paths in the spec. + +**Resolved by:** evidence. The version-agreement rule now covers every record that publishes a version, and listing +membership covers both storefronts. A "Channels and targets" section grounds the vocabulary once. + +**Affected decisions:** D20 (new) +**Affected tech-notes:** — +**Changed in spec:** Channels and targets (new section), Coordinations, Edge cases + +### F5: "Plugin" and "belongs in" were undefined, and they are the shared rule + +**Raised by:** `junior-developer` (JD-009), `devops-engineer` (DOR-006), and `edge-case-explorer` (Q3), convergently. + +**Finding.** The spec committed to deriving the plugin list from the repository without saying what makes a directory a +plugin, and defined "belongs in" only by subtraction from its one exception. Two edge-case rows depended on the missing +definition. If the rule were "has a channel-one manifest", a plugin published only to channel two would be invisible — +today's bug mirrored onto the other channel. An ambiguous question cannot have one answer, which undercuts F3's fix. + +The edge-case-explorer added the constraint that settles it: the bundle has a manifest and **no skills at all**, +permanently and legitimately. So "has skills" cannot be part of the rule. + +**Resolved by:** evidence. Stated positively: a plugin is a directory the suite ships as an installable unit; every +plugin belongs in all four targets except the bundle, which belongs only in channel one's. Detection is left to the +implementation plan. + +**Affected decisions:** D19 (new), D6 (extended to exempt the bundle from version agreement too) +**Affected tech-notes:** — +**Changed in spec:** Channels and targets, Primary flow (Step 3), Edge cases + +### F6: The Outcome stated as fact what the technical note flags as unverified + +**Raised by:** `junior-developer` (JD-010) and `devops-engineer` (DOR-007), convergently. + +**Finding.** The Outcome asserted flatly that channel-two users "are offered updates again", citing T1 — the very note +that labels itself a single-source provided claim, records that no independent confirmation was obtained, and says +verification is "worth doing before the spec's outcome is quoted to anyone". The citation made the Outcome look +supported by the note that undermines it. Worse, T1's named verification action was owned by no step and no open item, +despite being the only unknown in the document with a named cost and a named consequence. + +**Resolved by:** evidence, and the evidence improved. A codebase source was found that corroborates the mechanism class: +the versioning doc describes the version field as what a channel uses "to detect that updates are available". That is +about channel one, so it raises T1 from single-source-provided to provided-plus-corroborated-by-analogy, but it does not +confirm channel two. The Outcome is now hedged and points at the open item; T1 carries the corroboration and its +limits; verification is Open item 1 with a named owner. + +**Affected decisions:** D10 (unaffected — it stands on its own evidence) +**Affected tech-notes:** T1 (evidence upgraded, limits stated) +**Changed in spec:** Outcome, Primary flow (Step 4), Open items + +### F7: Step 2's guarantee was circular, and its protection was at the wrong layer + +**Raised by:** `edge-case-explorer` (Q1, Q2), with `junior-developer` (JD-014) reaching the circularity independently. + +**Finding.** Three distinct problems: + +1. **Circular promise.** "Every work item is accounted for" — but a work item is *defined* as a heading matching a + recognized pattern. So the promise reduced to "every item we recognize is accounted for", which was already true. + Malformed headings (a plain hyphen instead of the em-dash separator, a lowercase prefix, a missing ID) still vanish. + The hyphen case is highly plausible: the em-dash needs a special input method and most copy-paste produces a hyphen. + The publisher's own skill already lists "Malformed heading" as an expected finding, so this is not hypothetical. +2. **All-or-nothing was ambiguous, and the natural implementation violates it.** The create loop re-scans from the top + each iteration for the first unannotated heading. The natural extension — detect the foreign shape when the scan + reaches it — would create every item *before* it and then halt, contradicting "stops before creating anything". + Real GitHub issues would exist. +3. **The protection was specified one layer too low.** The real user path runs the skill's prose-driven repair pass + *before* the script. That pass recognizes exactly two valid shapes and would bucket a foreign annotation as a generic + "Malformed heading", route it to "propose the corrected shape", and potentially strip or reformat it — defeating the + gate before the script ever sees it. The fix would have protected only direct script invocation, not the documented + workflow. + +**Resolved by:** evidence on all three. The whole file is examined before the first item is published; "accounted for" +is defined so every heading is published, skipped-and-counted, or surfaced; and the foreign-annotation category exists +at every layer that inspects a heading, not only the last. + +**Affected decisions:** D3 (strengthened), D17 (rewritten), D30 (new) +**Affected tech-notes:** — +**Changed in spec:** Primary flow (Step 2), Edge cases, User interactions + +### F8: The document survey was incomplete, and the charter was drawn in the wrong place + +**Raised by:** `information-architect` (F1–F5, F8) and `junior-developer` (JD-005), convergently on the charter. + +**Finding, part one — five verified misses.** The survey behind D7 found seven locations. Five more exist, every one +verified verbatim: + +- The orientation document new readers are told to read first narrates **both** deleted edges ("Each of these four + depends on `han-core`"; "Each of these three depends on `han-core` like the other layers"). The survey skipped the + most-read document in the set. +- The canonical long-form doc for the feedback plugin promises the install "pulls `han-core` along the way" — a claim a + reader will act on and see contradicted by the install output. +- A second sentence in the choosing guide ("Both pull `han-core` along the same way the other layers do") — the survey + named only one sentence in that file. +- The line *after* the contributor-guide claim already in scope is independently false and more dangerous: it tells a + contributor that the feedback plugin "may dispatch any `han-core` agent freely", which it structurally cannot. +- The versioning doc's reasoning that the single suite tag suffices rests partly on the feedback plugin's dependency + array, which after step 5 does not exist. + +**Finding, part two — the charter.** The junior-developer found that the release repair and the check falsify two *more* +documents that step 6's charter never covered: the contributor guide tells someone adding a plugin to update one of four +targets, and the versioning doc instructs syncing to one listing with zero mentions of channel two. The +information-architect located the gap more precisely: it is not step 6's charter that is too narrow, it is the +**Outcome** that has no bullet for the release procedure. Widening step 6 would make it a grab-bag of two unrelated jobs +sequenced against step 5 when half of it depends on the release repair. + +**Verified.** All five misses confirmed verbatim. The versioning doc contains zero occurrences of the word "codex". + +**Resolved by:** evidence. Step 6's scope is now a **rule** — a document is in scope when this work falsifies it — not a +list. A second Outcome bullet was added for the release procedure, and those documents are owned by the step that +falsifies them (the release repair), not by the document-correction step. + +**Affected decisions:** D7 (restated as a rule), D9 (extended and its rationale re-grounded), D21 (new) +**Affected tech-notes:** — +**Changed in spec:** Outcome (new bullet), Primary flow (Steps 3 and 6) + +### F9: The tutorial's honesty contract is broken, and repointing alone makes it worse + +**Raised by:** `information-architect` (F6, F7). + +**Finding.** D8's evidence cited the tutorial's "The versions in this guide are the versions on disk" as proof it is +grounded in the real suite. That sentence is false: every number it names is wrong (it claims four plugins at 1.0.0 and +the bundle at 3.0.0; they are at 2.2.1, 2.2.2, 2.1.1, 2.0.0, and 4.6.0). So D8 used a broken promise as the reason to +keep real plugin names — and the plan would have repointed the *names* in that very paragraph while leaving the +*promise* standing. An untouched lie is a bug; a lie a maintainer edited around and left is a signal the doc is +unmaintained. + +It fails a second way: the manifest the tutorial prints for the reporting plugin is *already* wrong in the opposite +direction from step 5, and substituting the coding plugin reproduces the identical falsehood with a new name, because +both really declare two dependencies rather than one. + +**Verified.** All five version numbers confirmed wrong. The reporting plugin really declares two dependencies. **But the +sentence carries its own escape hatch** — "If you are reading the manifests and the numbers differ, the manifests are +right; this guide is describing the shape, not pinning the numbers" — which the finding did not quote. That makes the +document self-defusing and downgrades this from the "most-false line" the finding called it. It also settles the fix: +the doc already argues for its own answer. + +**Resolved by:** evidence. The version promise is dropped and the existing shape caveat covers the document. The +information-architect's alternative (print the real manifests and sync every release) was rejected as a maintenance +obligation nobody asked for — the finding itself flagged that option as a YAGNI candidate. + +D8's *outcome* survives intact: the substitution works. The information-architect verified the Linear plugin is a +structural identity for the feedback plugin's pedagogical role, and set aside its own channel-two concern (the tutorial +never mentions channel two, and step 1 publishes the Linear plugin there before step 6 runs anyway). + +**Affected decisions:** D8 (rationale corrected), D27 (new) +**Affected tech-notes:** — +**Changed in spec:** Primary flow (Step 6) + +### F10: The obvious fix reproduces the defect being fixed + +**Raised by:** `information-architect` (F9). + +**Finding.** The contributor-guide claim in scope fails as a **universal quantifier that outlived its truth**. The +obvious fix — swap it for an enumeration — has the same half-life: it is false the day someone adds a plugin, and +nothing in the repository will notice. That is precisely the standing-defect shape diagnosed in the release process, +relocated into prose. Separately, the orientation document carries two hardcoded counts ("these four", "these three") in +the very passages step 6 must edit, and this repository's own convention is that indexes stay complete, not counted. + +**Resolved by:** evidence. Corrected documents state the dependency **rule** and point at the manifests as the record, +rather than restating manifest contents in prose. The orientation document's counts go with them. + +**Affected decisions:** D26 (new), D9 (rationale re-grounded on "it is false and contributors read it" rather than "the +editor is already open", which would license exactly the sweep the finding warns about) +**Affected tech-notes:** — +**Changed in spec:** Primary flow (Step 6) + +### F11: The circular edge case, and the local hooks that are opt-in + +**Raised by:** `junior-developer` (JD-006, JD-015) and `devops-engineer` (DOR-011), convergently. + +**Finding.** Two related overclaims: + +1. The spec said the check "runs both before a commit lands and on every pull request", and that a contributor + therefore learns while still working. Verified false: the contributor guide says "**If you want** pre-commit hooks, + run `npx prek install`". Hooks are opt-in and not installed by default, so for everyone else the check is + pipeline-only — the option D4 explicitly rejected as "the slower loop". +2. The edge-case row answering "a release is cut from a branch where a step has not landed" answered it with "the check + has already failed on that branch's pull request". Circular: during the whole window the order governs, the check + does not exist. And the release skill explicitly permits cutting from a non-default branch, while the pipeline only + runs on pull requests and pushes to the default branch — so a branch with no pull request gets no run at all. This + was the row that would otherwise have caught F1. + +The actors table's promise that the order is "stated and enforced" was the same overclaim: nothing enforces it. + +**Resolved by:** evidence. The check blocks on every pull request; the local half is named as a convenience for +contributors who opted in. The edge-case row now says what actually stops that release: the release's own gate. This +also removes the argument that the release-side gate is redundant with the pipeline — it is not. The pipeline protects +the default branch after the fact; the gate protects the pre-tag moment. + +**Affected decisions:** D4 (rewritten), D24 +**Affected tech-notes:** — +**Changed in spec:** Actors and triggers, Primary flow (Step 7), Alternate flows and states, Coordinations + +### F12: Step 2 shares nothing with the other six steps + +**Raised by:** `junior-developer` (JD-002). + +**Finding.** The Outcome's third bullet uses a different verb than its heading. One is Han-the-product being published +to marketplaces; the other is a user's work-items file being published to an issue tracker. Step 2 shares no actor, no +artifact, no target, no failure mode, and no code with any other step. The check does not check it; the release does not +touch it. No decision argued for its inclusion — and the folder-naming decision argues the other way, naming the folder +for the publishing pipeline "because that is the actual problem". The only justification was provenance: the source plan +listed it. That is inheritance, not evidence. + +**Resolved by:** user input. All seven steps are retained by explicit instruction. The spec now records the seam +honestly rather than manufacturing a coupling: step 2 is named as a distinct concern, independently schedulable, kept +here because it was asked for. The finding's third option — argue the coupling is real — was rejected outright as the +kind of reasoning this spec's YAGNI rule exists to catch. + +**Affected decisions:** D23 (new) +**Affected tech-notes:** — +**Changed in spec:** Primary flow (Step 2) + +### F13: The new version record had no stated value, and it was the one real inter-step coupling + +**Raised by:** `junior-developer` (JD-004). + +**Finding.** Step 1 creates a channel-two version record for the Linear plugin and the spec never said what version it +gets. The choice determines whether the step-1-to-step-4 dependency exists at all: set it to the plugin's channel-one +version and step 4 never touches it; set it to a "matching the other codex manifests" value and step 1 has deliberately +introduced a defect for step 4 to repair. This was the single most concrete inter-step coupling in the plan, and it was +the one the spec did not mention while asserting six couplings it did not have. + +**Resolved by:** evidence. The record is created at the plugin's channel-one version, which follows directly from the +one-version-per-plugin decision and dissolves the coupling. + +**Affected decisions:** D22 (new) +**Affected tech-notes:** — +**Changed in spec:** Primary flow (Step 1) + +### F14: The spec was unreadable without artifacts a reader does not have + +**Raised by:** `junior-developer` (JD-008). + +**Finding.** "The first channel", "the second channel", "storefront listing", "package manifest", "target" — none were +ever defined. The spec never named the two products, never said what the four targets were, and its only resolving link +was an artifact URL that resolves for approximately one person. The reviewer had to be told the mapping in its brief; a +reader coming to the file cold could not construct it. "The release updates all four targets rather than two" is +unactionable to anyone who cannot enumerate the four. + +**Resolved by:** evidence. A "Channels and targets" section grounds the vocabulary once, at the top, naming the two +products and describing the four targets behaviorally. Everything downstream stays channel-neutral and becomes readable. +No file paths entered the spec; they remain in this log and the decision log where they belong. + +**Affected decisions:** — +**Affected tech-notes:** — +**Changed in spec:** Channels and targets (new section), header + +## Minor edits + +- **F15:** The release-stop "names every gap" behavior cited evidence that steps 1 and 4 retire — by the time the + behavior exists, the multi-gap state it cited is fixed. Better evidence existed and was not cited: one omitted new + plugin produces three simultaneous gaps, which is the routine case. — `junior-developer` (JD-012) — D12's evidence + swapped; behavior unchanged. +- **F16:** The listing-without-a-plugin edge case was the only row in the table with no decision citation, justified + solely by "the same class of defect" — the symmetry anti-pattern verbatim. The behavior is right and real evidence + exists (this repository has already renamed every plugin directory in one commit, mechanically identical to + delete-old-path-add-new; and a listing entry resolving to nothing breaks the install-succeeds promise directly). — + `junior-developer` (JD-013) and `edge-case-explorer` (Q4) — kept, promoted to D29 with its real evidence. +- **F17:** "Not only the two the source plan named" is provenance about the source artifact, not behavior. A reader + cannot act on it, and it implicitly certified a survey that F8 proved incomplete. — `information-architect` (F10) — + removed from the spec; the enumeration lives in D7. +- **F18:** The bundle-support edge case pointed at "an open item" but the entry is correctly in Deferred (YAGNI). — + `junior-developer` (JD-017) — repointed. +- **F22:** Open item 3 (blast radius of a format change on files in people's repositories) said of itself that it + "matters to the deferred namespacing work, not to step 2" — by its own admission it belongs to a spec that does not + exist yet. — `junior-developer` — removed from this spec's open items; it travels with the namespacing work. +- **F23:** The partial-failure behavior across the four targets was unstated, but the fix is one sentence, not rollback + machinery: every write happens before anything irreversible, which is the same sentence the gate placement needed. — + `devops-engineer` (DOR-008) — added to Alternate flows; folded into D24, which carries the same sentence. +- **F24:** No kill switch is specified for the check, and none should be. Building one is the "land it disabled" + alternative already rejected; reverting the single commit is the escape hatch. Recorded so the implementation plan + does not add one. — `devops-engineer` (DOR-009) — D28 added; recorded in Deferred (YAGNI) as a deliberate + non-decision. +- **F25:** No speculative operational machinery was proposed and none should be. Recorded so the boundary is not crossed + during implementation. — `devops-engineer` (DOR-010) — added to Deferred (YAGNI). +- **F26:** "A plugin exists in the repository but in no storefront listing", read literally, means absent from *both* — + which is not the shape of the bug that motivated the work (present in one channel, absent from the other). — + `edge-case-explorer` (Q3) — row reworded to "missing from any target it belongs in, including just one of two + channels", against D19's positive definition. +- **F27:** The spec never said whether manifest descriptions count as documents. The sweep came back clean so it costs + nothing today, but the next person running the rule needs to know. — `information-architect` (F14) — D25 added. + +## Findings verified and rejected + +- **F19: "The tutorial's version claim is the most-false line in the document."** — `information-architect` (F6). The + five version numbers are indeed all wrong, and the finding's *conclusion* was adopted. But the finding did not quote + the sentence's second half, which explicitly anticipates divergence: "If you are reading the manifests and the numbers + differ, the manifests are right; this guide is describing the shape, not pinning the numbers." The document defuses + its own claim. The severity was overstated; the remedy (drop the promise, keep the caveat) was right and is adopted as + D27. +- **F20: Jira and Linear may create duplicate tickets for work already published to GitHub.** — + `edge-case-explorer` (Q2), rated High. Verified as real and correctly reasoned: a GitHub-shaped annotation matches + neither tracker's already-published pattern, and the Linear format doc's "any other form is unannotated and eligible + for creation" is explicit. Surfaced to the user with the evidence and the mitigation (both skills carry a human + confirmation gate before creating). **The user ruled it out of scope and directed that it not be recorded as a spec + commitment.** Noted here for traceability only; it is not an open item and no step addresses it. +- **F21: A pre-flight membership check should run before the release does its work.** — `devops-engineer` (DOR-002, + offered as P1 and explicitly marked ergonomic rather than correctness-driven). Not adopted: the single gate at D24 + satisfies both halves of the rule, and the finding itself says "do not add it in step 6". Recorded so it is not + rediscovered. diff --git a/docs/plans/han-publishing-cleanup/feature-specification.md b/docs/plans/han-publishing-cleanup/feature-specification.md new file mode 100644 index 00000000..ea2f7fef --- /dev/null +++ b/docs/plans/han-publishing-cleanup/feature-specification.md @@ -0,0 +1,397 @@ +# Feature Specification: Han Publishing Cleanup + +Source: the "What I would do, in order" section of a cleanup plan for this repository. The seven steps are all retained. +Their execution order differs from the source's listing order, deliberately and with the reason recorded +([D18](artifacts/decision-log.md#d18-the-execution-order-is-a-partial-order-and-the-repair-precedes-the-correction)). + +Companion artifacts: + +- [Decision log](artifacts/decision-log.md) +- [Team findings](artifacts/team-findings.md) +- [Technical notes](artifacts/feature-technical-notes.md) + +## Channels and targets + +The spec stays channel-neutral throughout. This section is the one place the vocabulary is grounded, so the rest of the +document is readable without it. + +- **Channel one** — the Claude Code marketplace. Healthy. Nothing here changes what it publishes. +- **Channel two** — the Codex marketplace. The one that has been rotting. +- **A storefront listing** — the per-channel catalogue that says which plugins exist. One per channel, two total. +- **A version record** — a place that states a plugin's published version. There are **three**, not two: each channel + carries a per-plugin version record, and channel one's storefront listing also carries a version per plugin. Channel + two's storefront listing carries no version at all + ([D20](artifacts/decision-log.md#d20-version-agreement-covers-every-record-that-publishes-a-version)). +- **A target** — any of the four files a release must keep current: two storefront listings and two per-plugin version + records. Channel one's listing is both a listing and a version record, which is why the four targets do not partition + evenly into "two and two". +- **A plugin** — a directory the suite ships as an installable unit + ([D19](artifacts/decision-log.md#d19-a-plugin-and-the-targets-it-belongs-in-are-defined-positively)). +- **The bundle** — the meta-plugin that installs the suite in one command. It exists on channel one only + ([D6](artifacts/decision-log.md#d6-the-bundle-is-a-permanently-named-exception-on-the-second-channel)). + +## Outcome + +Han is published completely and honestly to both of the places it ships to, and a check makes it impossible to quietly +stop being so. + +Concretely, when this work is done: + +- Every Han plugin advertised as installable on a channel is actually installable on that channel. Following the + documented install instructions produces a working install, not an error. +- People who installed Han on channel two are offered updates again, once the mechanism this rests on is confirmed + ([T1](artifacts/feature-technical-notes.md#t1-update-availability-on-channel-two-is-decided-by-the-published-version-number), + [Open item 1](#open-items)). +- Publishing work items to a tracker never loses a work item without saying so. +- Every dependency a plugin declares is one it actually uses, so the declarations can be trusted to answer "what breaks + if I change this?" +- Every document that describes Han's plugin topology describes the topology that exists. +- **Every document that describes Han's release procedure describes the procedure that exists**, so a contributor + following the contributor guide is not blocked by the check for doing what the guide told them + ([D21](artifacts/decision-log.md#d21-the-release-procedure-documents-are-owned-by-the-step-that-falsifies-them)). +- The release process starts from what is really in the repository, covers every target, and stops rather than shipping + around a gap ([D5](artifacts/decision-log.md#d5-the-check-derives-the-plugin-list-from-the-repository)). +- A check enforces all of the above on every change, and it is green from the day it lands + ([D1](artifacts/decision-log.md#d1-the-check-lands-last-because-a-check-that-blocks-everything-gets-disabled)). + +## Actors and triggers + +| Actor | Trigger | What they need from this feature | +| ------------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------- | +| Han maintainer | Works through the seven steps once | Each step lands without breaking the next; the real ordering constraints are stated | +| Han maintainer | Cuts any future release | The release covers every target and refuses to ship around a missing plugin | +| Contributor | Opens a pull request that adds or renames a plugin | The check tells them what is missing, and the contributor guide already told them the four targets | +| Channel-two installer | Runs the documented install command for any advertised plugin | The install succeeds | +| Channel-two installer | Has Han installed and expects updates | Updates are offered when releases happen | +| Work-items publisher user | Publishes a work-items file to a tracker after using another one | Nothing is dropped without an explicit, loud signal | + +## Primary flow + +Seven steps, executed in the order below. Only some adjacencies are forced; the forced ones are named and the rest are +free ([D18](artifacts/decision-log.md#d18-the-execution-order-is-a-partial-order-and-the-repair-precedes-the-correction)). + +The binding constraints are: **the check is last**; **the release repair precedes the version correction**; **the +declaration deletion and the document correction are one unit**; and the work-items fix is independent of all of it. + +### Step 1: Publish the Linear plugin to channel two + +_Source position: 1._ + +The Linear plugin is advertised in channel two's setup instructions and was never published there. Someone following +those instructions gets an error today. + +After this step, the Linear plugin appears in channel two's storefront listing and carries its own version record, so +the documented install command succeeds. Its new version record is created at the version channel one already publishes +for it, so it is correct on arrival rather than created wrong for a later step to repair +([D22](artifacts/decision-log.md#d22-the-new-version-record-is-created-at-the-plugins-channel-one-version)). + +This goes first because it is the only step where a person following the project's own instructions hits an error right +now ([D11](artifacts/decision-log.md#d11-step-1-goes-first-because-it-is-a-live-broken-promise)). + +### Step 2: Close the GitHub publisher's silent hole + +_Source position: 2. Independent of every other step._ + +When the GitHub work-items publisher meets a work item already annotated by a different tracker, that work item is +neither published nor reported as skipped. It disappears from the run with no error and no signal. + +After this step, every work item in the file is accounted for in every run: every heading is published, +skipped-and-counted, or surfaced +([D30](artifacts/decision-log.md#d30-accounted-for-is-defined-so-the-promise-is-not-circular)). A work item whose +annotation the publisher does not recognize is surfaced as a format error, and **the run stops before creating anything +at all** — the whole file is examined before the first item is published, so a foreign annotation late in the file cannot +be preceded by items already created +([D3](artifacts/decision-log.md#d3-a-foreign-annotation-stops-the-run-before-anything-is-created)). + +The protection lives at every layer that inspects a heading, not only the last one. The publisher's own repair pass +recognizes a foreign annotation as a distinct category that it must not silently repair, so the annotation reaches the +gate rather than being tidied away before it +([D17](artifacts/decision-log.md#d17-the-foreign-annotation-category-exists-at-every-layer-that-inspects-a-heading)). + +This step does not change the annotation format and requires no migration +([D2](artifacts/decision-log.md#d2-step-2-closes-the-silent-hole-only-annotation-namespacing-is-separate)). It shares no +actor, artifact, or failure mode with the other six steps and is retained here by explicit instruction rather than by a +dependency ([D23](artifacts/decision-log.md#d23-step-2-is-a-distinct-concern-retained-by-instruction)). + +### Step 3: Teach the release process about every target + +_Source position: 6. Moved ahead of the version correction — see +[D18](artifacts/decision-log.md#d18-the-execution-order-is-a-partial-order-and-the-repair-precedes-the-correction)._ + +The release process updates two of the four targets, and takes its list of plugins from one of the targets it also +writes. It therefore cannot detect that the other two have fallen behind, which is why the drift went unnoticed across +roughly twenty releases. + +After this step, a release derives the set of plugins from what is actually in the repository +([D5](artifacts/decision-log.md#d5-the-check-derives-the-plugin-list-from-the-repository)) and updates all four targets. + +A release refuses to proceed when a plugin is missing from a target it belongs in, or when a plugin's version records +disagree. **The gate runs after the release has written all four targets and before it commits, tags, pushes, or +publishes anything** — early enough that every action after it is still local and reversible, late enough to judge the +state actually being released rather than the state before it +([D24](artifacts/decision-log.md#d24-the-gate-runs-after-all-targets-are-written-and-before-anything-irreversible)). +When it stops, it names every gap it found rather than the first +([D12](artifacts/decision-log.md#d12-a-missing-plugin-stops-the-release-and-every-gap-is-named)). + +The release holds no copy of the rule. It runs the check and reports what the check says +([D14](artifacts/decision-log.md#d14-the-release-runs-the-check-rather-than-restating-it)). + +This step also corrects the documents that describe the release procedure, because this step is what makes them wrong +([D21](artifacts/decision-log.md#d21-the-release-procedure-documents-are-owned-by-the-step-that-falsifies-them)). + +One exception is permanent and named: the bundle cannot be published to channel two, because that channel does not +support bundles. The rule knows this about that one plugin specifically and neither flags its absence nor applies the +version-agreement rule to it, since a plugin on one channel has nothing to disagree with +([D6](artifacts/decision-log.md#d6-the-bundle-is-a-permanently-named-exception-on-the-second-channel)). + +### Step 4: Correct the frozen version numbers + +_Source position: 3. Moved after the release repair._ + +Channel two's published version numbers have not moved since the day they were created, so that channel never offers +anyone an update +([T1](artifacts/feature-technical-notes.md#t1-update-availability-on-channel-two-is-decided-by-the-published-version-number)). + +After this step, each plugin's channel-two version matches the version channel one publishes for that same plugin +([D10](artifacts/decision-log.md#d10-the-two-channels-publish-one-version-per-plugin)). + +Because the release process was repaired first, this correction is durable: the very next release keeps it correct +rather than re-freezing it. Had this step come first, any release cut before the repair would have undone it +([D18](artifacts/decision-log.md#d18-the-execution-order-is-a-partial-order-and-the-repair-precedes-the-correction)). + +### Step 5: Delete the two untrue dependency declarations + +_Source position: 4. One unit with step 6._ + +Two plugins declare that they need the core plugin and never touch it. The reporting plugin's use moved elsewhere and +the declaration was left behind. The feedback plugin is not permitted to invoke other plugins at all, so its declaration +cannot be true. + +After this step, the reporting plugin declares only what it uses, and the feedback plugin declares nothing. Installing +either no longer drags in a large plugin the installer will never use, and every remaining declaration in the suite is +one the declaring plugin actually uses +([D13](artifacts/decision-log.md#d13-both-untrue-declarations-are-deleted-rather-than-made-true)). + +### Step 6: Correct every document the declaration deletion falsifies + +_Source position: 5. Ships together with step 5 — a merged state where the declarations are gone and the documents still +narrate them is the state this step exists to prevent._ + +The test for inclusion is a rule, not a list: **a document is in scope when this work falsifies it**, and the step that +falsifies it owns it. Not "mentions a plugin", not "narrates topology", not "sits nearby" +([D7](artifacts/decision-log.md#d7-a-document-is-in-scope-when-this-work-falsifies-it)). This step owns the documents the +declaration deletion falsifies. Step 3 already owns the ones the release repair falsifies +([D21](artifacts/decision-log.md#d21-the-release-procedure-documents-are-owned-by-the-step-that-falsifies-them)). + +After this step, no document describes a dependency the suite does not have. That includes the contributor guide, the +orientation document new readers are told to read first, the canonical long-form doc for the affected plugin, and the +tutorial that teaches plugin dependencies. It also includes one document that already described a topology the suite +never had, found while surveying the others +([D9](artifacts/decision-log.md#d9-the-already-false-contributor-claim-is-in-scope)). + +Manifest descriptions are documents and are in scope. Manifest dependency declarations are the record itself, not a +description of it, and are step 5's business +([D25](artifacts/decision-log.md#d25-manifest-descriptions-are-documents-the-declarations-are-the-record)). + +The corrected documents state the dependency **rule** and point at the manifests as the record, rather than restating +the manifests' contents in prose. Swapping a stale universal claim for a stale enumeration reproduces the same defect +with a longer half-life, and hardcoded counts are already a violation of this repository's own convention +([D26](artifacts/decision-log.md#d26-corrected-documents-state-the-rule-and-point-at-the-record)). + +The tutorial that teaches plugin dependencies by walking through the two deleted edges has its worked example repointed +at edges that remain real, keeping its teaching shape +([D8](artifacts/decision-log.md#d8-the-tutorials-worked-example-repoints-to-surviving-real-edges)). Its claim to print +the real on-disk version numbers is dropped rather than repaired, because that claim is already false and keeping it +would create a maintenance obligation nobody asked for +([D27](artifacts/decision-log.md#d27-the-tutorial-teaches-shape-and-stops-promising-real-version-numbers)). + +### Step 7: Turn on the automated check + +_Source position: 7. Last, and this is the one hard ordering constraint the source plan argued for._ + +Only now does the rule become blocking. The rule itself has existed since step 3, because the release runs it; this step +is what makes it refuse a pull request +([D14](artifacts/decision-log.md#d14-the-release-runs-the-check-rather-than-restating-it)). It verifies that every plugin +in the repository appears in every target it belongs in, and that a plugin's version records agree. + +It runs on every pull request, and additionally on the machines of contributors who have installed the optional local +hooks ([D4](artifacts/decision-log.md#d4-the-check-blocks-on-every-pull-request-and-runs-locally-where-hooks-are-installed)). + +Because steps 1 through 6 have already landed, the check is green on the day it arrives and stays green +([D1](artifacts/decision-log.md#d1-the-check-lands-last-because-a-check-that-blocks-everything-gets-disabled)). Every +problem above becomes impossible to reintroduce. + +There is no disable switch, deliberately +([D28](artifacts/decision-log.md#d28-the-check-ships-with-no-disable-switch)). + +## Alternate flows and states + +**The check lands before the problems are fixed.** It fails immediately on nearly every plugin and blocks every release +and pull request from its first day. The check would be correct and the repository would not be ready for it. The +predictable outcome is that someone disables it, at which point it protects nothing and the repository is worse off than +before by one broken thing +([D1](artifacts/decision-log.md#d1-the-check-lands-last-because-a-check-that-blocks-everything-gets-disabled)). + +**A release runs while a plugin is missing from a target.** The release stops after writing the targets and before +committing +([D24](artifacts/decision-log.md#d24-the-gate-runs-after-all-targets-are-written-and-before-anything-irreversible)), +naming every gap it found rather than the first +([D12](artifacts/decision-log.md#d12-a-missing-plugin-stops-the-release-and-every-gap-is-named)). Nothing is tagged, +pushed, or published. Recovery is discarding local changes. + +**A release fails partway through writing the four targets.** Every write happens before anything irreversible, so the +repository is left with local modifications and nothing published. Recovery is discarding local changes and re-running. +No compensation or rollback machinery is specified because none is needed +([D24](artifacts/decision-log.md#d24-the-gate-runs-after-all-targets-are-written-and-before-anything-irreversible)). + +**A work-items run meets a file annotated by a different tracker.** The run stops before creating anything and names the +work items whose annotations it does not recognize, so a person decides whether the file was already published elsewhere +([D3](artifacts/decision-log.md#d3-a-foreign-annotation-stops-the-run-before-anything-is-created)). + +**A work-items run meets a file this same tracker already annotated.** Unchanged from today: those items are recognized +as already published, skipped, and reported in the skipped count. A partial run resumes cleanly. + +**A new plugin is added after this work.** The check fails on the pull request that adds it until the contributor lists +it in every target it belongs in. This is the intended behavior, and it is only defensible because step 3 corrected the +contributor guide to name all four targets rather than one +([D21](artifacts/decision-log.md#d21-the-release-procedure-documents-are-owned-by-the-step-that-falsifies-them)). + +**A release is cut from a branch where a step has not landed.** What stops it is the release's own gate, not a +pull-request check that may never have run — the release process permits cutting from a non-default branch, and a branch +with no pull request gets no pipeline run +([D24](artifacts/decision-log.md#d24-the-gate-runs-after-all-targets-are-written-and-before-anything-irreversible)). + +## Edge cases and failure modes + +| Case | Behavior | +| ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | +| The bundle is absent from channel two | Not flagged, and the version-agreement rule does not apply to it ([D6](artifacts/decision-log.md#d6-the-bundle-is-a-permanently-named-exception-on-the-second-channel)) | +| A plugin is missing from any target it belongs in, including just one of two channels | Check fails, naming the plugin and every target it is missing from. This is the shape of the defect that motivated the work ([D19](artifacts/decision-log.md#d19-a-plugin-and-the-targets-it-belongs-in-are-defined-positively)) | +| A plugin is in a storefront listing but not in the repository | Check fails. A listing entry resolving to nothing breaks the install-succeeds promise directly ([D29](artifacts/decision-log.md#d29-a-listing-entry-with-no-plugin-behind-it-fails-the-check)) | +| A plugin's version records disagree | Check fails, naming the plugin and every disagreeing record ([D20](artifacts/decision-log.md#d20-version-agreement-covers-every-record-that-publishes-a-version)) | +| A plugin has a manifest and no skills | Valid. The bundle is permanently in this state, so "has skills" is not part of what makes a directory a plugin ([D19](artifacts/decision-log.md#d19-a-plugin-and-the-targets-it-belongs-in-are-defined-positively)) | +| A work item's heading is malformed in any way the publisher does not recognize | Surfaced, never silently passed over. "Accounted for" means every heading is either published, skipped-and-counted, or surfaced ([D30](artifacts/decision-log.md#d30-accounted-for-is-defined-so-the-promise-is-not-circular)) | +| A work-items file mixes annotated and unannotated items for the same tracker | Unchanged: annotated items skipped and counted, unannotated items published | +| Two trackers' annotations are indistinguishable from each other | Out of scope here; the trap remains and is specified separately ([D2](artifacts/decision-log.md#d2-step-2-closes-the-silent-hole-only-annotation-namespacing-is-separate)) | +| Channel two adds bundle support later | The named exception becomes removable. See [Deferred (YAGNI)](#deferred-yagni) | + +## Coordinations + +- **The release process and the repository.** The release reads the set of plugins from the repository rather than from + a target it also writes, so a stale listing can no longer hide a plugin from it + ([D5](artifacts/decision-log.md#d5-the-check-derives-the-plugin-list-from-the-repository)). +- **The release process and both storefronts.** The release writes all four targets before it does anything + irreversible + ([D24](artifacts/decision-log.md#d24-the-gate-runs-after-all-targets-are-written-and-before-anything-irreversible)). +- **The check and the release process.** One rule, one bearer. The release runs the check and reports its answer rather + than restating the rule in its own words, so the two cannot drift + ([D14](artifacts/decision-log.md#d14-the-release-runs-the-check-rather-than-restating-it)). +- **The check and the pull-request pipeline.** The check blocks on every pull request. Local hooks are optional in this + repository, so the local half is a convenience for contributors who opted in, not a guarantee the feature rests on + ([D4](artifacts/decision-log.md#d4-the-check-blocks-on-every-pull-request-and-runs-locally-where-hooks-are-installed)). +- **The three work-items publishers and the shared work-items file.** All three read and annotate the same file. Step 2 + changes only how one of them responds to annotations it does not recognize; it does not change what any of them + writes. +- **Step 5 and step 6.** They ship together + ([D18](artifacts/decision-log.md#d18-the-execution-order-is-a-partial-order-and-the-repair-precedes-the-correction)). + The intermediate state — declarations deleted, documents still narrating them — is exactly what step 6 exists to + prevent. + +## User interactions + +The people who experience this feature are maintainers and contributors at a terminal, plus installers on channel two. + +- **Check failure.** Names the plugin and every target it is missing from. This is the same commitment as the release + stop below rather than a second one: the check and the release share one bearer + ([D14](artifacts/decision-log.md#d14-the-release-runs-the-check-rather-than-restating-it)), so the message a + contributor sees on a pull request is the message a maintainer sees on a release + ([D12](artifacts/decision-log.md#d12-a-missing-plugin-stops-the-release-and-every-gap-is-named)). Naming the target is + possible because "belongs in" is defined rather than left to the exception + ([D19](artifacts/decision-log.md#d19-a-plugin-and-the-targets-it-belongs-in-are-defined-positively)). +- **Release stop.** Names every gap it found before stopping, not just the first one, so a maintainer learns the full set + in one run ([D12](artifacts/decision-log.md#d12-a-missing-plugin-stops-the-release-and-every-gap-is-named)). +- **Work-items format error.** Names the specific work items whose annotations were not recognized and what they appear + to be annotated by, so the person can tell "already published to another tracker" from "file is malformed". This + requires "malformed" to be a detectable category rather than a residue + ([D30](artifacts/decision-log.md#d30-accounted-for-is-defined-so-the-promise-is-not-circular)). +- **Channel-two installer.** Experiences no interface. They get a working install command and, thereafter, update + prompts. The absence of an error is the whole user-facing outcome. + +## Out of scope + +- **Namespacing every tracker's annotations so none can be mistaken for another's.** A real trap with real evidence, but + the strictly simpler step 2 satisfies the evidence that drove it here + ([D2](artifacts/decision-log.md#d2-step-2-closes-the-silent-hole-only-annotation-namespacing-is-separate)). It carries + a format change and a migration for files already in people's repositories, which is a specification of its own. +- **Connecting the planning plugin to the shared writing standard.** Considered, reasoned about, recorded as out of + scope, challenged in review, and confirmed. Reopening it is a fresh decision made on purpose, not a gap to patch. +- **Declaring the relationship between the planning plugin and the three ticket publishers as a dependency.** Declaring + a dependency forces an install rather than annotating a relationship, so this would compel every GitHub user to + install the whole planning plugin to document a connection no code follows. The relationship belongs in the + documentation. +- **Consolidating the duplicated rule files.** The case for it is stronger than earlier analysis suggested, since a rule + has already been edited by hand in three places. It is not one of the seven steps and is not blocked by any of them. +- **Changing what channel one publishes.** Channel one is healthy. This work brings channel two up to it. +- **Documents already stale for reasons this work does not touch.** Several enumerations elsewhere in the repository are + out of date but are not falsified by this work, and no evidence suggests anyone has been misled by them. Fixing them + because the editor is already open is the symmetry reasoning this spec's own rules reject + ([D7](artifacts/decision-log.md#d7-a-document-is-in-scope-when-this-work-falsifies-it)). +- **Agreement between a plugin's own name as recorded on each channel.** Plausible by analogy to the version drift, but + no instance exists and nothing depends on it today. + +## Deferred (YAGNI) + +- **A check that every declared dependency is actually used by the declaring plugin.** This would have caught step 5's + two untrue declarations automatically, which makes it tempting. It fails the evidence test: nobody has asked for it, + no incident is attributed to it, and the two known instances are being deleted by hand. Building a general mechanism + from two instances is machinery ahead of need. **Reopening trigger:** a third decorative dependency is found, or a + decorative dependency causes a real install or breakage problem. +- **Designing for channel two gaining bundle support.** The exception is named and permanent today + ([D6](artifacts/decision-log.md#d6-the-bundle-is-a-permanently-named-exception-on-the-second-channel)). Building a + configurable exception mechanism for one known exception is speculative. **Reopening trigger:** channel two ships + bundle support, or a second permanent exception appears. +- **A disable switch for the check.** Deferred deliberately, not overlooked. A disable mechanism is the "land it + disabled" alternative already rejected, and reversibility already exists: the check lands in one commit, so reverting + that commit is the escape hatch. **Reopening trigger:** the check produces its first false positive on a pull request + that is actually correct ([D28](artifacts/decision-log.md#d28-the-check-ships-with-no-disable-switch)). +- **Monitoring channel two's published state from outside the repository.** No dashboard, no version polling, no alert on + release deviation. The check on every pull request is the signal, and the drift persisted for twenty releases + precisely because nothing asked the question at all — not because nobody was watching a graph. + **Reopening trigger:** drift recurs despite the check, meaning the check is asking the wrong question. + +## Open items + +1. **Whether channel two gates update availability on the published version number is unconfirmed**, and the Outcome's + update-prompt claim rests on it. This is the one open item with a named cost and a named consequence: verifying costs + one installed client and one release, and if it is wrong then step 4 is still worth doing but its user-facing claim + must come out of the Outcome. **Owner: the maintainer, before the Outcome is quoted to anyone.** See + [T1](artifacts/feature-technical-notes.md#t1-update-availability-on-channel-two-is-decided-by-the-published-version-number). +2. **Which revision each channel's client resolves from** — the default branch or the latest release tag — is unknown + from inside this repository. If it is the tag, then steps 1 and 4 reach users at the next release rather than on + merge, and both steps' outcomes should be worded accordingly. This is the same class of unknown as item 1: external + client behavior not visible from here. +3. **Whether the pull-request pipeline's lint job is a required status check** determines how much of the guarantee the + check actually carries. If it is not required, a pull request can merge red and the check is advisory on the one + surface that does not depend on a contributor's local setup. +4. **Version compatibility between plugins is an open question this specification does not close.** Plugins are + installed and updated one at a time, so someone can run a months-old coding plugin against a core plugin updated + today, and nothing would notice. Earlier analysis argued this could not happen because everything ships from a single + snapshot; that argument holds only for a fresh install of everything at once, which is not how the suite is used over + time. The right fix is not obvious. Flagged as a decision the team still owes itself, not as work this specification + schedules. + +## Summary + +- **Outcome.** Han publishes completely and honestly to both channels, and a check keeps it that way. +- **Actors.** Han maintainers, contributors, and channel-two installers. +- **Scope.** Seven steps. The check is last; the release repair precedes the version correction; the declaration + deletion and document correction are one unit; the work-items fix is independent. +- **Decisions.** 30 full and trivial combined. See [decision-log.md](artifacts/decision-log.md). +- **Technical notes.** 1. See [feature-technical-notes.md](artifacts/feature-technical-notes.md). +- **Sub-agents.** 4: junior-developer, devops-engineer, edge-case-explorer, information-architect. See + [team-findings.md](artifacts/team-findings.md). +- **Key adjustments from review.** The execution order was changed to close a window that would have undone step 4; the + gate was given a placement; the check and release were given one bearer instead of two; the document survey was + restated as a rule and grew by five locations; and a factual error about the number of version records was corrected. +- **Deferred under YAGNI.** 4. +- **Open items.** 4, of which two are unverified external behaviors and one is a real decision the team owes itself. From 1a7aff4021dd53c1a38abb74115c790b3a4758de Mon Sep 17 00:00:00 2001 From: River Lynn Bailey <river.bailey@testdouble.com> Date: Thu, 16 Jul 2026 13:34:41 -0600 Subject: [PATCH 02/22] docs(plans): review han-publishing-cleanup feature specification Round 1 of iterative-plan-review, team mode, spec-aware. Five specialists: junior-developer, adversarial-validator, evidence-based-investigator, devops-engineer, edge-case-explorer. 16 findings (14 major, 2 minor). The load-bearing corrections: - The check cannot block a merge. main is unprotected, the sole ruleset is disabled, and it carries no required_status_checks rule even if enabled. Three agents converged on this from different angles. The guarantee is now stated per surface: the release refuses, a pull request reports. The real enforcement was always the release gate, not the check. - Steps 3 and 4 become one unit. The gate goes live at step 3, but nine version gaps stay open until step 4, so shipping 3 without 4 froze every release in between. - The repaired release could detect a missing target but not create one, so its headline promise never fired for the case that motivated the work. It now creates missing records (D31). - han-linear is a third untrue han-core declaration, not two. It was also the plugin D8 had chosen as the tutorial's example of a *real* dependency edge. - "Roughly twenty releases" is eleven, verified by tag ancestry. - The bundle publishes a version in two channel-one records, so exempting it from version agreement left the suite's most-exercised hand-sync unchecked. Adds review-findings.md and review-iteration-history.md. Adds D31-D35 and T2. Corrects D4, D6, D8, D13, D22, D25, D26, D29 against evidence that falsified their rationales. Round 1 was not stable; round 2 is in progress. --- .../artifacts/decision-log.md | 534 +++++++++++++++--- .../artifacts/feature-technical-notes.md | 41 ++ .../artifacts/review-findings.md | 531 +++++++++++++++++ .../artifacts/review-iteration-history.md | 83 +++ .../feature-specification.md | 415 ++++++++++---- 5 files changed, 1408 insertions(+), 196 deletions(-) create mode 100644 docs/plans/han-publishing-cleanup/artifacts/review-findings.md create mode 100644 docs/plans/han-publishing-cleanup/artifacts/review-iteration-history.md diff --git a/docs/plans/han-publishing-cleanup/artifacts/decision-log.md b/docs/plans/han-publishing-cleanup/artifacts/decision-log.md index 23f4fe0e..5c9be541 100644 --- a/docs/plans/han-publishing-cleanup/artifacts/decision-log.md +++ b/docs/plans/han-publishing-cleanup/artifacts/decision-log.md @@ -113,38 +113,54 @@ stopping and asking rather than guessing". ### D4: The check blocks on every pull request, and runs locally where hooks are installed -**Outcome.** The check blocks on every pull request. It additionally runs before a commit for contributors who have -installed the optional local hooks. The guarantee rests on the pull-request half; the local half is a convenience. - -**Rationale.** Rewritten after review. The original entry claimed the check "runs both before a commit lands and on -every pull request" and that a contributor therefore learns while still working. That is false for anyone who has not -opted in, and this repository's hooks are opt-in. Claiming the local half as a delivered behavior would have made the -spec promise something it cannot. - -The pull-request half is the real guarantee precisely because it does not depend on anyone's local setup. Adding the -check to the existing lint path means it rides both surfaces for free, so contributors who did install hooks get the -faster loop at no extra cost. +_Heading kept for stable cross-references. Its central claim is false and is corrected below: the check **reports** on +every pull request; it does not block._ + +**Outcome.** The check runs on every pull request and reports a failure a person can merge past. It additionally runs +before a commit for contributors who have installed the optional local hooks. **Neither surface is a guarantee.** The +guarantee rests on the release gate ([D32](#d32-the-guarantee-is-stated-per-surface-because-only-the-release-can-refuse)). + +**Rationale.** Rewritten twice. The first rewrite corrected the smaller of two overclaims and promoted the larger one. +It removed the false claim that a contributor learns before committing (true only for those who opted into hooks) and +certified the pull-request half as "the real guarantee precisely because it does not depend on anyone's local setup." +That certification rested on evidence that the lint job is *triggered* by every pull request — which establishes that it +runs, and says nothing about whether it blocks. + +Corrected on evidence in the second review: nothing in this repository makes any check blocking. The default branch is +unprotected, no rules apply to it, and the sole ruleset is disabled and contains no required-check rule +([T2](feature-technical-notes.md#t2-a-pull-request-check-blocks-a-merge-only-where-a-required-status-check-demands-it)). +So the honest position is that this decision picks a *placement*, not an enforcement: putting the check in the lint path +means it rides both surfaces for free and shows a contributor the gap at the moment they created it. That is worth +having. It is not a guarantee, and the spec no longer sells it as one. **Evidence.** codebase — `package.json` defines `"lint": "prek run --all-files"` and `.github/workflows/ci.yml`'s lint -job runs `npm run lint` on every pull request and every push to `main`. codebase — `CONTRIBUTING.md:59` reads "If you -want pre-commit hooks, run `npx prek install`", so hooks are opt-in and absent from a fresh clone. codebase — -`.pre-commit-config.yaml` already carries local hooks and shellcheck, so a new local hook is the established pattern. -codebase — `test/sanity.bats` and `"test": "bats --recursive test/"` give the check a testing home. provided — the user -selected this option. +job runs `npm run lint` on every pull request and every push to `main`. This is the evidence the first rewrite mistook +for enforcement: it proves execution only. live platform configuration — see +[T2](feature-technical-notes.md#t2-a-pull-request-check-blocks-a-merge-only-where-a-required-status-check-demands-it); +the default branch is unprotected and the one ruleset is disabled with no required-check rule. codebase — +`CONTRIBUTING.md:59` reads "If you want pre-commit hooks, run `npx prek install`", so hooks are opt-in and absent from a +fresh clone. codebase — `.pre-commit-config.yaml` already carries local hooks and shellcheck, so a new local hook is the +established pattern. codebase — `test/sanity.bats` and `"test": "bats --recursive test/"` give the check a testing home. +provided — the user selected this option. **Rejected alternatives.** - _Claim the local half as a guarantee._ Rejected on evidence: hooks are opt-in, so the claim is false for the default contributor. +- _Claim the pull-request half as a guarantee._ Held after the first review and rejected on evidence in the second: + nothing makes it blocking, so it informs rather than guarantees. - _Make hook installation mandatory._ Rejected: no evidence anyone wants it, and it changes the contributor setup contract to buy a faster loop the pipeline already provides more reliably. - _Pipeline only, with no local hook._ Rejected: the local run is free once the check is in the lint path, and it does help the contributors who opted in. -- _Inside the release process only._ Rejected: drift could still land on the default branch between releases. - -**Driven by findings:** F11 -**Linked technical notes:** — -**Dependent decisions:** D14, D24 +- _Inside the release process only._ Rejected originally because "drift could still land on the default branch between + releases." **That rejection is now known to be unfounded** — pipeline-only, in the sense of non-blocking, is exactly + what ships, so drift *can* land on the default branch. The decision to also run it on pull requests survives on its + own merit (early, cheap signal), not on preventing that. + +**Driven by findings:** F11, F28 +**Linked technical notes:** T2 +**Dependent decisions:** D14, D24, D32 **Referenced in spec:** Primary flow (Step 7), Coordinations ### D5: The check derives the plugin list from the repository @@ -178,22 +194,38 @@ source artifact's "Before" diagram: "DISK -.-> |never consulted directly| REL". ### D6: The bundle is a permanently named exception on the second channel -**Outcome.** The bundle is a named, permanent exception. Its absence from channel two is never flagged, **and the -version-agreement rule does not apply to it** — a plugin published on one channel has nothing to disagree with. +**Outcome.** The bundle is a named, permanent exception **to the comparison between channels**. Its absence from channel +two is never flagged, and it is never asked to agree with a channel-two record it does not have. It is **not** exempt +from the agreement between channel one's own two records, which it does have. **Rationale.** The limitation is real, external, and documented with a specific upstream tracking issue. An exception the rule does not know about would fire on every run forever, and a check that always reports one known failure trains people to ignore it. -Extended after review. The original entry stated the exception as presence-only. The bundle is also structurally exempt -from version agreement, which was implied rather than stated. +Corrected during review, twice over. The original entry stated the exception as presence-only. It was then extended to +exempt the bundle from version agreement entirely, on the rationale that "a plugin published on one channel has nothing +to disagree with" — which is false. The bundle publishes a version in two records, channel one's storefront listing and +its own manifest, and D20 deliberately brought that exact pair into the rule. The blanket exemption and D20 collided, +and the collision resolved the wrong way. + +The bundle is also the worst possible plugin to exempt. It bumps on every release, so its listing-versus-manifest +hand-sync runs more often than any child's, and the release tag is named after its version. If one release's hand-sync +misses it, the tag says one version, the manifest agrees, and the storefront advertises the previous one — silently and +permanently, because the rule was told not to look. That is the D10 drift class, on the one plugin the whole suite is +tagged by, introduced by the fix for drift. **Evidence.** codebase — `README.md:74` states "Codex does not yet support meta-plugins like `han@han` (see openai/codex#23531,) and it resolves no dependencies", and the Codex install instructions list every plugin individually -rather than the bundle. codebase — `.agents/plugins/marketplace.json` omits `han` and there is no `han/.codex-plugin/`, -consistent with a deliberate exclusion. codebase — `han/.claude-plugin/plugin.json` exists with no `skills/` directory at +rather than the bundle. web (corroborating, independently fetched) — `openai/codex#23531` is a real open issue whose body +confirms Codex has no plugin-dependency mechanism today, so the README's claim is corroborated rather than merely +repeated. codebase — `.agents/plugins/marketplace.json` omits `han` and there is no `han/.codex-plugin/`, consistent with +a deliberate exclusion. codebase — `han/.claude-plugin/plugin.json` exists with no `skills/` directory at all, which is what makes the bundle permanently zero-skill (see [D19](#d19-a-plugin-and-the-targets-it-belongs-in-are-defined-positively)). -provided — the source artifact: "the check needs to know about it permanently rather than flagging it forever." +codebase — `.claude-plugin/marketplace.json` publishes `han` at 4.6.0 and `han/.claude-plugin/plugin.json` publishes +4.6.0: two records, both carrying a version, currently agreeing. codebase — `docs/semantic-versioning.md:160-172` and +`.claude/skills/han-release/SKILL.md:148-149` ("The parent always bumps on every release") and `:54` ("The release tag is +`v{parent target}`"). provided — the source artifact: "the check needs to know about it permanently rather than flagging +it forever." **Rejected alternatives.** @@ -202,8 +234,11 @@ provided — the source artifact: "the check needs to know about it permanently - _Publish a bundle to channel two anyway._ Rejected: the channel does not support it. - _Let the check flag it and have people ignore that one line._ Rejected: a permanently red check is a disabled check with extra steps, which is the failure D1 exists to avoid. +- _Exempt the bundle from version agreement entirely._ Held briefly after the first review and rejected on evidence: its + rationale was factually wrong, and it silently excluded the most frequently hand-synced version pair in the suite from + the rule written to protect version pairs. -**Driven by findings:** F5 +**Driven by findings:** F5, F33 **Linked technical notes:** — **Dependent decisions:** D14, D19, D20 **Referenced in spec:** Channels and targets, Primary flow (Step 3), Edge cases, Deferred (YAGNI) @@ -256,27 +291,42 @@ the source artifact, which names a count but not the documents. ### D8: The tutorial's worked example repoints to surviving real edges **Outcome.** `docs/how-to/extend-han-with-plugin-dependencies.md` keeps its worked-example structure and repoints it at -edges that remain true. The opt-in-leaf role passes from `han-feedback` to `han-linear`; the layer-on-top-of-core role -passes from `han-reporting` to `han-coding`. - -**Rationale.** The tutorial does not merely mention the two edges; it teaches by walking through them. The roles those -plugins play are still filled by real plugins, so the lesson survives intact with different subjects. `han-linear` is a -structural identity for `han-feedback`'s role: it depends on `han-core`, is not in the bundle's dependencies, and is -installed on its own. - -Rationale corrected after review. The original entry justified keeping real plugin names by citing the tutorial's claim -to print real on-disk versions. That claim is itself false, so it could not support anything. The real justification is -narrower and still sufficient: the tutorial teaches a topology, the topology still exists, and a reader can still go read -the real manifests being described. What the tutorial should stop claiming is handled separately in +edges that remain true. The opt-in-leaf role passes from `han-feedback` to `han-atlassian`; the layer-on-top-of-core +role passes from `han-reporting` to `han-coding`. + +**Rationale.** The tutorial does not merely mention the deleted edges; it teaches by walking through them. The roles +those plugins play are still filled by real plugins, so the lesson survives intact with different subjects. + +Repoint target corrected after the second review. The original entry passed the opt-in-leaf role to `han-linear`, on the +reasoning that it "is a structural identity for `han-feedback`'s role: it depends on `han-core`, is not in the bundle's +dependencies, and is installed on its own." The first two thirds of that are true and the load-bearing third is not: +`han-linear`'s declaration is decorative, which review established and which is why step 5 now deletes three +declarations rather than two ([F31](review-findings.md#f31-han-linear-is-a-third-untrue-dependency-declaration-and-the-specs-own-deferral-trigger-has-already-fired)). +Repointing there would have taught a lesson about true dependency edges using an edge that is about to be deleted for +being false — reproducing, inside the fix, the exact defect the rewrite exists to remove. + +The replacement must satisfy two tests, not one: the edge survives this work, **and** the edge is real. `han-atlassian` +passes both. It fills the same role — depends on `han-core`, absent from the bundle's dependencies, installed on its +own — and its dependency is one it actually exercises, so it is still true after step 5 runs. + +Rationale corrected after the first review. The original entry justified keeping real plugin names by citing the +tutorial's claim to print real on-disk versions. That claim is itself false, so it could not support anything. The real +justification is narrower and still sufficient: the tutorial teaches a topology, the topology still exists, and a reader +can still go read the real manifests being described. What the tutorial should stop claiming is handled separately in [D27](#d27-the-tutorial-teaches-shape-and-stops-promising-real-version-numbers). -**Evidence.** codebase — `han-linear/.claude-plugin/plugin.json` declares `"dependencies": ["han-core"]` and is absent -from `han/.claude-plugin/plugin.json`'s dependencies, matching the tutorial's ":169" role ("a leaf that nothing else -points to: it depends on core, but the meta-plugin does not bundle it"). codebase — -`han-coding/.claude-plugin/plugin.json` declares `["han-communication", "han-core"]`, matching the ":108" -"second layer on top of core" role. codebase — the tutorial never mentions Codex; it teaches `.claude-plugin/plugin.json` -and `/plugin install …@han` throughout, so `han-linear`'s channel-two absence does not undercut it as an exemplar — and -step 1 publishes it there before step 6 runs anyway. provided — the user selected this option. +**Evidence.** codebase — `han-atlassian/.claude-plugin/plugin.json` declares +`["han-communication", "han-core", "han-planning", "han-coding"]` and is absent from `han/.claude-plugin/plugin.json`'s +dependencies, matching the tutorial's ":169" role ("a leaf that nothing else points to: it depends on core, but the +meta-plugin does not bundle it"). codebase — the dependency is verified **by use, not by self-description**: +`han-atlassian/skills/project-documentation-to-confluence/SKILL.md:50` invokes `han-core:project-documentation` with the +`Skill` tool, and `:15` of the sibling wrapper skills grants `Skill` and `Agent` in `allowed-tools`, so the plugin both +can and does call the core plugin. This test is deliberate: the manifest description also asserts the dependency, but a +description asserting a dependency is exactly the evidence that was false for `han-linear`, so it cannot be what +qualifies the replacement. codebase — `han-coding/.claude-plugin/plugin.json` declares +`["han-communication", "han-core"]`, matching the ":108" "second layer on top of core" role. codebase — the tutorial +never mentions Codex; it teaches `.claude-plugin/plugin.json` and `/plugin install …@han` throughout. provided — the +user selected the repoint-rather-than-rewrite option. **Rejected alternatives.** @@ -285,10 +335,15 @@ step 1 publishes it there before step 6 runs anyway. provided — the user selec - _Leave it and add a correction note._ Rejected: a tutorial with a note saying its example is wrong is a tutorial nobody should follow. - _Delete the tutorial._ Rejected: no evidence it is unwanted; the topology it teaches still exists. +- _Pass the opt-in-leaf role to `han-linear`._ Held after the first review and rejected on evidence in the second: its + `han-core` declaration is one of the three step 5 deletes, so the example would be false by the time the tutorial + shipped. +- _Pass the opt-in-leaf role to `han-plugin-builder`._ Rejected: it is opt-in and unbundled, but it depends on nothing + at all, so it cannot illustrate a dependency edge. -**Driven by findings:** F9 +**Driven by findings:** F9, F31 **Linked technical notes:** — -**Dependent decisions:** D27 +**Dependent decisions:** D13, D27 **Referenced in spec:** Primary flow (Step 6) ### D9: The already-false contributor claim is in scope @@ -405,31 +460,48 @@ three gaps at once, so the multi-gap case is the routine one, not a relic of tod ### D13: Both untrue declarations are deleted rather than made true -**Outcome.** The reporting plugin's and the feedback plugin's declarations on the core plugin are deleted. Neither is -made true by introducing a use. +_Heading kept for stable cross-references. The count is now three — see the correction below._ + +**Outcome.** The reporting plugin's, the feedback plugin's, and the Linear plugin's declarations on the core plugin are +deleted. None is made true by introducing a use. + +**Rationale.** None of the three uses the core plugin, and none wants to. The reporting plugin's use genuinely moved to +the communication plugin and the declaration is a leftover. The feedback plugin and the Linear plugin cannot invoke +another plugin even in principle, so no edit short of changing what they are permitted to do could make their +declarations true. Deleting is the change that makes the declaration match reality; anything else changes reality to +match a leftover. -**Rationale.** Neither plugin uses the core plugin, and neither wants to. The reporting plugin's use genuinely moved to -the communication plugin and the declaration is a leftover. The feedback plugin cannot invoke another plugin even in -principle, so no edit short of changing what it is permitted to do could make its declaration true. Deleting is the change -that makes the declaration match reality; anything else changes reality to match a leftover. +Count corrected after the second review. The entry named two declarations because the source plan did and because the +survey behind it checked two plugins. `han-linear` carries the same defect with the same signature and was missed. The +correction matters past arithmetic: `han-linear` was the plugin +[D8](#d8-the-tutorials-worked-example-repoints-to-surviving-real-edges) had chosen as the tutorial's replacement example +of a *true* dependency edge, and the spec's Outcome would have stayed false after step 5 shipped as originally scoped +([F31](review-findings.md#f31-han-linear-is-a-third-untrue-dependency-declaration-and-the-specs-own-deferral-trigger-has-already-fired)). **Evidence.** codebase — `grep -rn "han-core" han-reporting/` outside its manifest returns nothing; its skills reference only `han-communication:readability-guidance` and `han-communication:readability-editor`. codebase — `han-feedback/skills/han-feedback/SKILL.md:9` declares `allowed-tools: Read, Write, Bash(ls *), Bash(mkdir *), Bash(gh *), Bash(date *)` — no `Agent` and no `Skill`, so it -structurally cannot invoke `han-core`. Its every "han-core" mention is a string literal it writes into a report. provided -— the source artifact: "it is not permitted to call other plugins at all, so its claim cannot possibly be true." +structurally cannot invoke `han-core`. Its every "han-core" mention is a string literal it writes into a report. +codebase — `han-linear/.claude-plugin/plugin.json:5` declares `"dependencies": ["han-core"]` and `:3` narrates it; +`grep -rn "han-core" han-linear/` returns only the manifest; and its skill's `allowed-tools` grants +`Read, Write, Edit, Glob, Grep, Bash(find *)` plus Linear MCP tools, with no `Agent` and no `Skill` — the identical +signature. `git log -p --follow` shows the declaration present since the plugin was introduced, so it is not a +regression. provided — the source artifact: "it is not permitted to call other plugins at all, so its claim cannot +possibly be true." **Rejected alternatives.** -- _Leave them; the cost is only a needless install._ Rejected: the cost is trust. Once two declarations are decorative, +- _Leave them; the cost is only a needless install._ Rejected: the cost is trust. Once some declarations are decorative, none of them answers "what actually breaks if I change this?" - _Give the feedback plugin the ability to invoke the core plugin so its declaration becomes true._ Rejected: inventing a use to justify a leftover. +- _Leave `han-linear` out of scope as a late discovery._ Rejected by the user: it would have left the Outcome's + declarations-are-trustworthy promise false on the day the work finished, and left D8 teaching from it. -**Driven by findings:** — +**Driven by findings:** F31 **Linked technical notes:** — -**Dependent decisions:** D7, D18 +**Dependent decisions:** D7, D8, D18, D25 **Referenced in spec:** Primary flow (Step 5) ### D14: The release runs the check rather than restating it @@ -652,24 +724,41 @@ Alternate flows and states **Outcome.** Step 1 creates the Linear plugin's channel-two version record at the version channel one already publishes for it. -**Rationale.** Step 1 must choose a version and the spec did not say which. The choice determines whether a step 1 to -step 4 dependency exists at all: create it at the channel-one version and the correction step never touches it; create it -at some "matching the other codex manifests" value and step 1 has deliberately introduced a defect for a later step to -repair. This follows directly from D10, and it dissolves the coupling rather than managing it. +**Rationale.** Step 1 must choose a version and the spec did not say which. The choice determines how much of a step 1 +to step 4 dependency exists: create it at the channel-one version and the record arrives correct; create it at some +"matching the other codex manifests" value and step 1 has deliberately introduced a defect for a later step to repair. +This follows directly from D10. + +Corrected during review. The original entry claimed this "dissolves the coupling rather than managing it." It does not. +Creating the record inside the Linear plugin's own directory is itself a change to that directory, which obliges the +plugin to bump at the next release — and until step 3 lands, a release moves the plugin's channel-one version without +touching the channel-two record step 1 just created. Step 1 manufactures one instance of the drift step 4 repairs, and +it does so by existing. This is the same mechanism F1 used to reorder step 4 ahead of nothing and behind the repair; it +was simply not applied to step 1, which also writes a channel-two version record. + +What the decision still buys is real but smaller: the record arrives correct, and the window in which it can go stale is +bounded by one release rather than open-ended. The coupling is managed, not dissolved, and the step 3 and 4 unit +([D18](#d18-the-execution-order-is-a-partial-order-and-the-repair-precedes-the-correction)) heals this instance along +with the eight it was already healing. **Evidence.** codebase — `han-linear/.claude-plugin/plugin.json` is at `1.0.2`. codebase — every other plugin's `.codex-plugin/plugin.json` was created at some `1.x` unrelated to its channel-one number, which is the pattern that -produced the drift D10 fixes; repeating it here would manufacture a ninth instance. +produced the drift D10 fixes; repeating it here would manufacture a ninth instance. codebase — +`docs/semantic-versioning.md:118` ("A child bumps only when its own directory changed") and `:166` (new files are a +minor bump), which together make step 1's own file creation a bump trigger for `han-linear`. **Rejected alternatives.** -- _Create it at 1.0.0 to match the other codex manifests._ Rejected: manufactures a defect for step 4 to repair, and - perpetuates the pattern D10 exists to end. +- _Create it at 1.0.0 to match the other codex manifests._ Rejected: manufactures a worse defect for step 4 to repair, + and perpetuates the pattern D10 exists to end. - _Leave it unstated._ Rejected: it is the single most concrete inter-step coupling in the plan. +- _Move step 1 after the step 3 and 4 unit so its record is durable on arrival._ Considered during review and rejected: + it would delay the only live user-facing error in the plan behind its largest step, which is the trade D11 already + refused. The manufactured drift is one instance, healed by a unit that is healing eight others anyway. -**Driven by findings:** F13 +**Driven by findings:** F13, F34 **Linked technical notes:** — -**Dependent decisions:** — +**Dependent decisions:** D18 **Referenced in spec:** Primary flow (Step 1) ### D23: Step 2 is a distinct concern retained by instruction @@ -755,28 +844,42 @@ declarations are the record itself, not a description of it, and are the deletio **Rationale.** The spec said "no document describes a dependency the suite does not have" without saying whether manifests count. Description fields are user-facing prose rendered in the storefront, so they can be false in exactly the -way the rule cares about. The sweep came back clean, so this costs nothing today — but the next person applying the rule -needs to know whether manifests are in it, and "we checked and it was fine" is a real result worth recording. +way the rule cares about, and the next person applying the rule needs to know whether manifests are in it. + +Correction after the second review: **the sweep was not clean, and the original entry said it was.** The sweep checked +the two manifests belonging to the two plugins then believed to carry untrue declarations, and concluded that manifest +descriptions were a boundary worth stating but a scope that cost nothing. It never checked `han-linear`'s description, +which narrates "Depends on han-core" — the same false claim its `dependencies` array makes, in the field this decision +exists to bring into scope. So this decision now has live work behind it rather than a clean bill of health, and the +lesson generalizes: the sweep was scoped to the plugins already suspected, which is exactly how the third declaration +stayed hidden ([F31](review-findings.md#f31-han-linear-is-a-third-untrue-dependency-declaration-and-the-specs-own-deferral-trigger-has-already-fired)). +The re-run covers every plugin's description, not every suspected plugin's. **Evidence.** codebase — `han-reporting/.claude-plugin/plugin.json` and `han-feedback/.claude-plugin/plugin.json` -description fields do not narrate dependencies. codebase — `.claude-plugin/marketplace.json`'s description for the bundle -narrates only the bundle's own dependencies, which this work does not change, so it stays true. codebase — -`.agents/plugins/marketplace.json` carries no dependency narration at all. +description fields do not narrate dependencies. codebase — `han-linear/.claude-plugin/plugin.json:3` **does**: it states +"Depends on han-core", which step 5 falsifies. codebase — `han-atlassian/.claude-plugin/plugin.json:3` narrates its +dependencies too, and they are true, so it stays as-is — which is what makes the field worth sweeping rather than +blanket-stripping. codebase — `.claude-plugin/marketplace.json`'s description for the bundle narrates only the bundle's +own dependencies, which this work does not change, so it stays true. codebase — `.agents/plugins/marketplace.json` +carries no dependency narration at all. **Rejected alternatives.** -- _Leave it unstated because the sweep was clean._ Rejected: the rule outlives this sweep and the next reader needs its - boundary. +- _Leave it unstated because the sweep was clean._ Rejected twice over: the rule outlives this sweep and the next reader + needs its boundary — and the premise turned out to be false. +- _Strip dependency narration from every manifest description._ Rejected: some are true and useful, and deleting true + prose to avoid re-checking it is the symmetry reasoning this spec rejects. -**Driven by findings:** F27 +**Driven by findings:** F27, F31 **Linked technical notes:** — -**Dependent decisions:** — +**Dependent decisions:** D13 **Referenced in spec:** Primary flow (Step 6) ### D26: Corrected documents state the rule and point at the record -**Outcome.** The corrected documents state the dependency rule and point at the manifests as the record, rather than -restating manifest contents in prose. Hardcoded counts in the affected passages go with them. +**Outcome.** A document making a **universal claim** about the dependency graph states the rule instead and points at +the manifests as the record. A document that legitimately **enumerates** the graph keeps the enumeration, drops any +hardcoded count, and names the manifests as the record. Hardcoded counts in the affected passages go either way. **Rationale.** The contributor-guide claim in scope fails as a universal quantifier that outlived its truth. The obvious fix — swap it for an enumeration of the plugins that do depend on core — has the same half-life: it is false the day @@ -785,6 +888,25 @@ release process, relocated into prose. Fixing a stale claim with a differently-s The repository already models the alternative and already has the convention. +Scoped during review. The original entry was reasoned entirely from one sentence shape — the contributor guide's "Every +plugin depends on `han-core`" — where "each plugin declares its own dependencies; read the manifest" genuinely replaces +the claim. It was then extended to "corrected documents" plural, including the orientation document and the agent-facing +project map, where it does not survive: **there is no rule that generates this suite's dependency graph.** The graph is +irregular by design — the planning plugin depends on the core plugin but not the communication plugin, the coding plugin +depends on both, two plugins depend on nothing. A document whose job is orientation legitimately narrates that +irregularity, and for the agent-facing map the whole point is that an agent reads one map instead of eleven manifests. +The only rule-shaped edit available there is to delete the narration and say "go read the manifests," which removes the +document's reason to exist and replaces a fixable staleness with a permanent gap. + +So the remedy is scoped to the shape it was reasoned from. The distinction that matters is between a claim that is wrong +because reality is irregular, and a listing that is merely long. The first must go; the second is the document doing its +job. + +A second correction: the original entry leaned on this repository's counting convention as though it settled the matter. +The convention is scoped to *indexes* ("Verify the indexes list every entity when editing them"). Applying it to prose +topology narration is a defensible extension and is not a rule the repository already states. Dropping the counts is +still right; the justification is by extension, not by letter. + **Evidence.** codebase — `CLAUDE.md` § Conventions: "**Indexes stay complete, not counted.** …Verify the indexes list every entity when editing them, rather than tracking a running total." codebase — `docs/concepts.md:221` ("Each of these **four** depends on `han-core`") and `:229` ("Each of these **three** depends on `han-core`") are hardcoded counts in the @@ -794,12 +916,16 @@ everything else pointing at it. **Rejected alternatives.** -- _Swap the universal claim for an enumeration._ Rejected: same defect, longer fuse. +- _Swap the universal claim for an enumeration._ Rejected **for universal claims**: same defect, longer fuse. This is + not the same as forbidding enumeration in a document whose job is to enumerate — see the scoping above. +- _Apply "state the rule, point at the record" to every corrected document._ Rejected on evidence during review: there + is no rule that generates the real graph, so for the orientation document and the agent-facing project map the remedy + reduces to deleting the content those documents exist to carry. - _Also de-duplicate the topology narration in the agent-facing project map, which states it three times in one file._ Rejected: real, but not falsified by this work and therefore outside D7's rule. The correction should not deepen it either. -**Driven by findings:** F10 +**Driven by findings:** F10, F36 **Linked technical notes:** — **Dependent decisions:** — **Referenced in spec:** Primary flow (Step 6) @@ -882,22 +1008,35 @@ Outcome's own first promise directly: the documented install command errors, whi starts by fixing. **Evidence.** codebase — both storefront listings reference plugins by relative path -(`"source": "./han-core"`-style), so a moved or removed directory leaves the entry dangling. codebase — this repository -has already renamed every plugin directory in a single commit (`d94daa2`, "Rename all plugins to use hyphens instead of -dots"), which is mechanically identical to delete-old-path-add-new; that commit updated the listings atomically, but it -establishes that the repository does move plugin directories. codebase — every listing entry resolves today, so this is -prevention of a demonstrated-possible edit, not a fix for a live break. +(`"source": "./han-core"`-style), so a moved or removed directory leaves the entry dangling. codebase — every listing +entry in both storefronts resolves to a real directory today (all 20 verified during review), so this is prevention, not +a fix for a live break. + +The honest justification is cost, not risk: **this behavior is free.** The rule already requires comparing the set of +plugins in the repository against the set named in each listing, in order to catch a plugin missing from a target. A +listing entry with nothing behind it is the same comparison read in the other direction. Nothing is built for it that is +not already being built. + +The `d94daa2` rename precedent, cited here originally, is withdrawn as evidence. It was offered to show "the repository +does move plugin directories," but that commit updated the listings in the same commit — so the one time this actually +happened, nothing broke. A precedent that demonstrates the failure has never occurred is not evidence the failure needs +guarding against; leaning on it dressed up "a directory move is possible" as a reason. The behavior stands on being +free. **Rejected alternatives.** -- _Defer it; nothing is broken today._ Rejected: it is the same set comparison as the direction already being built, the - rename precedent is real, and the failure it prevents is the Outcome's headline promise. +- _Defer it; nothing is broken today._ Rejected: it is the same set comparison as the direction already being built, so + deferring it saves nothing, and the failure it prevents is the Outcome's headline promise. - _Keep it justified by "same class of defect."_ Rejected: that is the reasoning the spec's own YAGNI rule names as an anti-pattern. The behavior survives; the justification did not. +- _Have the release repair a dangling entry itself, as it now repairs other membership gaps + ([D31](#d31-the-release-creates-a-missing-target-at-the-version-it-is-publishing))._ Rejected: this is the one + membership gap with two opposite remedies — delete the entry, or restore the plugin — and a release cannot know which + was intended. It stops and a person decides. -**Driven by findings:** F16 +**Driven by findings:** F16, F43 **Linked technical notes:** — -**Dependent decisions:** — +**Dependent decisions:** D31 **Referenced in spec:** Edge cases ### D30: "Accounted for" is defined so the promise is not circular @@ -936,6 +1075,217 @@ on the surrounding text" as a Step 3 validation finding, so malformed headings a **Dependent decisions:** — **Referenced in spec:** Primary flow (Step 2), Edge cases, User interactions +### D31: The release creates a missing target at the version it is publishing + +**Outcome.** A release brings a target up to date by creating a record that does not exist, not only by updating one +that has fallen behind. A record created this way is written at the version the release is publishing for that plugin — +for a plugin the release did not bump, that is the version it already has. + +**Rationale.** D5 changed what the release can see. It did not change what the release can write, and the difference is +the whole value of the repair. The release's version-writing step acts only on plugins whose target version differs from +their current one, and a newly added plugin is assigned no bump, so it always falls into the skip. The listing write +sets a version on an entry that already exists and has no defined behavior when none does. So for a brand-new plugin — +exactly the Linear-plugin shape that motivated this work — the "repaired" release would write nothing at all and the +gate would simply stop. The feature's headline promise would never once fire for the case it exists to serve, and every +future plugin addition would cost a hand-repair plus a re-run. + +Choosing the version is the sub-decision this forces, and the answer falls out of what a release is already doing. +Every other version the release writes is the version it is publishing; a created record that used anything else would +arrive disagreeing with its siblings and need a second pass to fix, reintroducing the two-step dance D22 removed from +step 1. Writing it at the publishing version means creation and agreement resolve together, and the gate that runs +afterward sees a consistent state on the first pass. + +"The version the release is publishing" is defined for every plugin, including the ones a release does not touch. A +release assigns each plugin a target version, and for a plugin with no changes that target is the version it already +carries. There is no plugin for which the phrase is undefined, so creation never has to guess. + +The four targets are not the same shape, and creation means the right thing in each. Three of them carry a version, so +creating an entry there writes one. Channel two's storefront listing carries no version at all — creating an entry +there means adding the plugin's membership and nothing more. This is the same asymmetry the Channels-and-targets +section already names, and it is why "create the record at the publishing version" is a rule about the three +version-bearing targets rather than about all four. + +This does not make the gate redundant. It still fires on every gap creation cannot close: a listing naming a plugin that +does not exist (which needs a person to decide whether the plugin or the entry is the mistake — D29), a record that +cannot be read, and a version value that cannot be parsed (D35). + +**Evidence.** codebase — `.claude/skills/han-release/SKILL.md:230` acts only "For every plugin whose `target` differs +from its `current`"; `:237-238` skips any plugin whose `target == current` and is "a no-op" when the whole plan is +ahead-path or new; `:160-161` assigns a new child "`baseline = current`, `target = current`, no bump", so a new plugin +always hits that skip; `:235-236` sets "the `version` of the `plugins[]` element whose `name` equals the plugin name", +which requires the element to exist. provided — the user chose creation over detection-only when the trade was surfaced +during review. + +**Rejected alternatives.** + +- _Detect only; reword the Outcome to "verifies every target"._ Rejected by the user. It is the smaller and more honest + version of what the release does today, but it leaves every future plugin addition to a hand-repair and leaves the + motivating case unserved by the fix built for it. +- _Detect now, defer creation to a follow-up with a reopening trigger._ Rejected: it splits one coherent capability + across two efforts and leaves the seven steps shipping a release that still cannot repair the defect they exist to + repair. + +**Driven by findings:** F30 +**Linked technical notes:** — +**Dependent decisions:** D5, D22, D24, D29, D35 +**Referenced in spec:** Outcome, Primary flow (Step 3), Alternate flows and states, Edge cases, Coordinations + +### D32: The guarantee is stated per surface, because only the release can refuse + +**Outcome.** The specification states what is enforced where: a release refuses to proceed; a pull request reports a +failure a person can merge past. The claim that the check makes problems "impossible to reintroduce" is deleted rather +than left unearned. + +**Rationale.** The spec's headline promise had no bearer. A pull-request check prevents a merge only where the hosting +platform is configured to require that status, and nothing in this repository is: the default branch is unprotected, no +rules apply to it, and the one ruleset that exists is disabled and contains no required-check rule, so enabling it as +written would not change the answer. Adding the check to the pipeline makes it run and report. It does not make it +block. + +Two things follow that are easy to miss. First, the real enforcement surface is the release gate, and the spec had the +two backwards — it treated the check as the guarantee and the gate as a supplement. Second, D1's rationale needed +restating rather than defending: "a check that blocks everything gets disabled" describes a mechanism that cannot occur +on a surface where nothing blocks. The ordering D1 argues for is still right, because the rule genuinely does start +refusing releases at step 3 — but the reason is the gate, not the check. A permanently red advisory check fails +differently: it gets ignored, and a signal nobody reads protects nothing. + +Stating the guarantee per surface is honest and cheap, and it keeps the check worth building: moving discovery from +release day to the pull request that caused the gap is real value even when the signal is advisory. + +**Evidence.** live platform configuration — see T2, queried directly and re-verified during review. codebase — +`.github/workflows/ci.yml:8-11` triggers the lint job on every pull request, which establishes that it runs and was +mistaken during planning for evidence that it blocks. provided — the user chose the honest downgrade over owning the +enforcement when the trade was surfaced during review. + +**Rejected alternatives.** + +- _Own it: add a required-status-check rule and enable the ruleset in step 7._ Rejected by the user for now, and + carried as a decision on Open item 3 rather than dropped. The existing disabled ruleset also demands an approving + review, which on a solo-maintained repository would block the maintainer from merging their own work and is the + likeliest reason it is off; requiring only the check is the smaller move if this is revisited. +- _Add an eighth step owning the repository settings._ Rejected: it grows the source plan's seven steps for a change + that is a settings toggle rather than work, and the decision to make it is not yet taken. + +**Driven by findings:** F28 +**Linked technical notes:** T2 +**Dependent decisions:** D1, D4, D14 +**Referenced in spec:** Outcome, Primary flow (Step 7), Alternate flows and states, Coordinations, Open items + +### D33: An already-false statement inside a rewritten passage is corrected + +**Outcome.** A false statement inside a passage this work is already rewriting is corrected. A false statement +elsewhere is not. + +**Rationale.** The spec was resolving this case three incompatible ways. D7 and the Out-of-scope section keep +already-stale documents out, because "the editor is already open" is convenience rather than evidence. D9 pulls an +already-false contributor claim in. D27 pulls an already-false tutorial promise in. Those are two inclusion rules, +neither generalized, both apparently contradicting the exclusion rule — and the implementer had no way to tell which +applied to a new instance. + +They are reconcilable, and the reconciliation is what D9 and D27 were already doing without saying so: both false +statements sit inside sentences the work rewrites anyway. That is a different situation from a stale enumeration three +files away. The test is not proximity to an open editor, which is the symmetry reasoning this spec rejects; it is +whether the sentence is being rewritten regardless. Leaving a known-false line inside a paragraph you are actively +correcting is its own kind of dishonesty, and it costs nothing to fix while you are there. + +A live instance forced the question: the orientation document's description of the bundle's own dependencies omits one +of them, is not falsified by this work, and sits directly between two lines step 6 must edit. + +**Evidence.** codebase — `docs/concepts.md:222-223` states the `han` meta-plugin "depends on `han-core`, +`han-planning`, `han-coding`, `han-github`, and `han-reporting`", while `han/.claude-plugin/plugin.json` declares +`["han-communication", "han-core", "han-planning", "han-coding", "han-github", "han-reporting"]` — `han-communication` +is omitted. The surrounding lines are in step 6's scope. + +**Rejected alternatives.** + +- _Leave already-false neighbors alone, strictly._ Rejected: it contradicts what D9 and D27 already decided, and it + requires the implementer to knowingly leave a false sentence in a paragraph they are rewriting. +- _Fix every already-false statement found while surveying._ Rejected: this is exactly D7's exclusion, and it turns a + bounded step into an open-ended documentation audit. + +**Driven by findings:** F37 +**Linked technical notes:** — +**Dependent decisions:** D7, D9, D27 +**Referenced in spec:** Primary flow (Step 6), Out of scope + +### D34: A gate stop costs a separate commit, because the release refuses a dirty tree + +**Outcome.** The specification states two recoveries rather than one. A partial write is recovered by discarding local +changes and re-running. A gate stop is recovered by correcting the gap and committing it, and only then re-running. + +**Rationale.** The spec gave both cases the same recovery, and for a gate stop it is a no-op that loops. The release +hard-stops when the working tree is dirty, so the sequence is: gate stops, discard local changes, the gap is still there +because it lives in the repository rather than in the release's uncommitted work, re-run, identical stop. A maintainer +following the documented recovery goes in a circle. + +That matters more than the friction, because there is no bypass by design (D28, correctly). With the documented +recovery looping and no disable switch, the move available under release pressure is to hand-edit the targets and force +through — shipping around the gap, which is the behavior this entire feature exists to end, performed by the person the +gate was built to protect. D28's stated escape hatch (revert the commit that added the check) is the answer to a *bad +check*, not to a correct gate stop mid-release. + +D31 reduces how often this fires — a release now creates what it can rather than stopping — so the remaining cases are +gaps creation cannot close. It does not eliminate them, so the recovery still needs stating. + +**Evidence.** codebase — `.claude/skills/han-release/SKILL.md:72-74`: "**Working tree must be clean.** If `working +tree` … is non-empty … Stop and tell the operator to commit or stash them first … This is a hard stop, not a pause +gate." + +**Rejected alternatives.** + +- _Add a bypass flag for gate stops._ Rejected: this is D28's disable switch under another name, and the case for it is + the pressure that makes shipping around gaps tempting in the first place. +- _Let the release stash and restore around the gate._ Rejected: implementation mechanics, and it hides a stop that + should be visible. + +**Driven by findings:** F38 +**Linked technical notes:** — +**Dependent decisions:** D24, D28, D31 +**Referenced in spec:** Alternate flows and states + +### D35: An unreadable record or version is surfaced, not skipped + +**Outcome.** A record that cannot be read is a surfaced, blocking failure, never a plugin quietly dropped from the set +being checked. A record whose version value is absent, empty, or unreadable fails the check by name, exactly as a +disagreement does, and two unreadable values are never treated as agreeing with each other. + +**Rationale.** The rule had no defined behavior for a record that is structurally broken rather than absent or +disagreeing, and the failure it invites is the one this whole feature exists to end. A plugin whose manifest cannot be +parsed would silently vanish from the derived list, so the check would faithfully answer "does every plugin appear +everywhere it should?" about a set that had already excluded the broken one. That is invisible by construction — the +same shape as the original bug, rebuilt inside the fix. + +This is not symmetry reasoning. The argument is not "malformed is handled for work-items headings, so handle it here +too"; it is that the failure is silent, the trigger is ordinary, and the code the derivation grows out of already +swallows exactly this error by default. The version half is the same argument one level down: a naive equality +comparison treats two empty values as agreement, which is a false negative on precisely the hand-sync class D20 was +written to catch. + +This does not make the derivation circular, which is the obvious objection: if a plugin's manifest cannot be read, how +does anything know the directory is a plugin? Because D19 defines a plugin by what the directory **has**, not by what +its manifest **says**. The manifest's presence is what makes the directory a plugin; the manifest's contents are what +the rule then checks. A directory carrying an unreadable manifest is therefore a plugin with a broken record — a +surfaced failure — rather than a non-plugin the derivation may quietly skip. The two questions are separable, and +keeping them separate is what closes the silent-drop path. + +**Evidence.** codebase — `.claude/skills/han-release/SKILL.md:37,39` both read manifests with `jq … 2>/dev/null`, +swallowing parse failures into empty output rather than an error; this is the established idiom in the skill D5 +extends. codebase — D20's own evidence establishes that channel one's listing-versus-manifest version is hand-synced +and unverified, so a broken value is the same manual slip that produced a stale one. The trigger is ordinary: a +hand-editing slip during steps 1 or 4, or an interrupted write from the release's own target-writing step. + +**Rejected alternatives.** + +- _Treat an unreadable record as a missing one._ Rejected: it produces the right stop for the wrong reason and would + invite a release to "create" a record over a file that already exists and is merely broken. +- _Leave it to implementation._ Rejected: the silent-skip behavior is the default of the code being extended, so + leaving it unstated is choosing it. + +**Driven by findings:** F40, F41 +**Linked technical notes:** — +**Dependent decisions:** D5, D19, D20, D24, D31 +**Referenced in spec:** Edge cases and failure modes + ## Trivial decisions - **D15: Output location** — `docs/plans/han-publishing-cleanup/`, alongside the existing plan folders. Named for the diff --git a/docs/plans/han-publishing-cleanup/artifacts/feature-technical-notes.md b/docs/plans/han-publishing-cleanup/artifacts/feature-technical-notes.md index 2451a577..8c598903 100644 --- a/docs/plans/han-publishing-cleanup/artifacts/feature-technical-notes.md +++ b/docs/plans/han-publishing-cleanup/artifacts/feature-technical-notes.md @@ -57,3 +57,44 @@ outcome reaches users then rather than on merge. **Supports decisions:** D10 **Driven by findings:** F6 **Referenced in spec:** Outcome, Primary flow (Step 4), Open items + +## T2: A pull-request check blocks a merge only where a required status check demands it + +**Context.** The spec committed to a user-visible behavior in its Outcome and in step 7: the check makes the problems +impossible to reintroduce, on every change. That claim is only correct because of the mechanic below, and the mechanic +does not hold here. Without it, the check on a pull request is a signal a person may merge past, not a gate. + +**Technical detail.** A check that runs on a pull request reports a status. Reporting a status and preventing a merge +are separate mechanisms: the merge is blocked only when the hosting platform is configured to require that specific +status before merging. Absent that configuration, a red check is advisory — visible, and mergeable. This repository has +no such configuration: the default branch is unprotected, no rules apply to it, and the one ruleset that exists is +disabled and does not contain a required-status-check rule at all, so enabling it would not change the answer. The +release-side gate is therefore the only surface in this feature that actually refuses to proceed. + +**Why this is not discoverable from the repository.** The pipeline definition lives in the repository and proves the +check runs. Whether a status is *required* lives in the hosting platform's repository settings, which are not files in +this codebase and are not visible to anyone reading it. This is why the gap survived: the repository shows the trigger, +and the trigger looks like enforcement. + +**Evidence quality.** live platform configuration, first-party, directly queried and independently re-verified during +review. This is a stronger tier than T1's: the fact was read from the authoritative system rather than inferred. + +- **live configuration (direct).** The default branch returns "not protected"; the rules applying to it are an empty + set; the sole ruleset is disabled and its rules are deletion, non-fast-forward, and a pull-request review-count rule, + with no required-status-check rule present. Queried by `evidence-based-investigator` and `junior-developer` + independently, and re-run during consolidation. +- **codebase (corroborates that the check runs, and only that).** `.github/workflows/ci.yml:8-11` triggers the lint job + on every pull request. This is what F11 read as evidence of enforcement; it establishes execution, not blocking. + +The net position: the fact is settled and adverse. Unlike T1, nothing here is unverified — the mechanic is confirmed, +and it confirms the spec's claim was wrong rather than unproven. + +**If this note is wrong.** It is not wrong in the sense T1 might be; it was read from the live system. It can, however, +become *stale*: enabling a required status check would restore the spec's original claim. That is a settings change no +step in this spec owns, and the spec now says so rather than assuming it. + +**Verification.** Already performed. Re-verify only if the repository's branch-protection configuration changes. + +**Supports decisions:** D4, D14, D32 +**Driven by findings:** F28 +**Referenced in spec:** Outcome, Primary flow (Step 7), Coordinations, Open items diff --git a/docs/plans/han-publishing-cleanup/artifacts/review-findings.md b/docs/plans/han-publishing-cleanup/artifacts/review-findings.md new file mode 100644 index 00000000..163db642 --- /dev/null +++ b/docs/plans/han-publishing-cleanup/artifacts/review-findings.md @@ -0,0 +1,531 @@ +# Review Findings: Han Publishing Cleanup + +Spec: [../feature-specification.md](../feature-specification.md) · Iterations: +[review-iteration-history.md](review-iteration-history.md) · Decisions: [decision-log.md](decision-log.md) · +Technical notes: [feature-technical-notes.md](feature-technical-notes.md) + +Findings from `iterative-plan-review`. Numbering continues from +[team-findings.md](team-findings.md), which reached F27 during `plan-a-feature`, so cross-references +stay unique across both artifacts. + +## Major findings + +### F28: The check cannot block a merge, so the Outcome's enforcement guarantee has no bearer + +**Agent:** `junior-developer` (JD-001), `devops-engineer` (DOR-013), and `evidence-based-investigator` (claim 10b) — +independently and convergently, from three different angles. + +**Category:** unsupported behavioral commitment + +**Finding.** The Outcome claimed "a check makes it impossible to quietly stop being so" and Step 7 claimed "Every +problem above becomes impossible to reintroduce." Nothing in this repository makes a pull-request check blocking. +Adding the check to the lint path makes it run and paint a red X; it does not make it block. No step created the +enforcement and no step owned it. + +**Evidence considered.** Live GitHub configuration, re-verified directly rather than taken from the agents: + +- `gh api repos/testdouble/han/branches/main/protection` → `404 Branch not protected`. +- `gh api repos/testdouble/han/rules/branches/main` → `[]`. Zero rules apply to `main`. +- `gh api repos/testdouble/han/rulesets` → one ruleset, named `main`, `"enforcement": "disabled"`. +- `gh api repos/testdouble/han/rulesets/16237928` → its rules are `deletion`, `non_fast_forward`, and `pull_request` + (1 approving review). **No `required_status_checks` rule exists**, so enabling the ruleset would still not make the + check block. +- `.github/workflows/ci.yml:8-11` triggers `lint` on `pull_request`. That proves the job runs. It was never evidence + that a pull request can be blocked. + +This is a trust-class correction as much as a factual one: F11 certified the pull-request half as "the real guarantee" +on evidence that only established the job runs. The larger overclaim was promoted while the smaller one was fixed. + +Two consequences beyond the bare fact: + +- **The only bearer of the guarantee is Step 3's gate, not Step 7.** The spec had the two backwards. +- **D1's stated rationale is not load-bearing on this repository.** D1 argues "a check that blocks everything on day + one gets disabled," and it is the spec's one hard ordering constraint. A check that blocks nothing cannot be disabled + that way; it gets ignored, which is a different failure with a different remedy. The ordering is still correct — the + release gate is genuinely blocking from Step 3 — but the reason had to be restated. + +**Resolution.** Downgraded honestly. The Outcome and Step 7 now state the guarantee per surface: blocking in the +release, advisory on pull requests. "Impossible to reintroduce" is deleted rather than left unearned. Open item 3 is +closed with the verified answer rather than left open, because the answer is known and it is the adverse one. D4's +rejection of "inside the release process only" is noted as resting on a premise the repository does not support. + +**Resolved by:** user input (option: downgrade the claim honestly), on evidence. + +**Raised in round:** R1 + +**Changed in plan:** Outcome, Primary flow (Step 7), Alternate flows and states, Coordinations, Open items, Summary + +**Changed in tech-notes:** T2 + +### F29: The gate goes blocking at Step 3, four steps before the drift it checks is fixed + +**Agent:** `junior-developer` (JD-002), with `devops-engineer` (DOR-013) reaching the same window independently. + +**Category:** unhandled failure mode in a primary flow path + +**Finding.** D14 gives the check and the release one bearer, so the rule is live from Step 3 — "the rule itself has +existed since step 3, because the release runs it." Step 3's gate refuses to proceed when a plugin's version records +disagree. Step 4 is what fixes the disagreement, and the reorder put it after. Every release cut between Step 3 and +Step 4 therefore hard-stops, with no disable switch by deliberate design (D28). + +This is D1's own failure mode, shipped by the spec's own reorder. The spec applied D1's reasoning only to Step 7 +("Because steps 1 through 6 have already landed, the check is green on the day it arrives"). The rule became blocking +four steps earlier, against a demonstrably red repository. The "check lands before the problems are fixed" alternate +flow was written as a rejected hypothetical; it was actually shipped, on the release surface. + +**Evidence considered.** Eight plugins have disagreeing version records at the moment Step 3 lands, verified by direct +read of every manifest (`evidence-based-investigator` claim 4): `han-core` 2.2.1/1.2.0, `han-planning` 2.0.4/1.0.0, +`han-coding` 2.6.0/1.0.0, `han-github` 2.2.2/1.2.0, `han-reporting` 2.1.1/1.0.1, `han-feedback` 2.0.0/1.1.1, +`han-atlassian` 2.2.0/1.1.0, `han-plugin-builder` 2.0.5/1.1.0. Only `han-communication` agrees. The gate checks every +plugin (D5 derives the list from the repository), not only the ones being bumped. `han-linear` adds a ninth gap of a +different shape — no channel-two record at all — which Step 1 both creates and, per F34, re-drifts. + +**Resolution.** Steps 3 and 4 are named as one unit, exactly as Steps 5 and 6 already are, and the unit is added to the +binding-constraints list. This is the structural fix rather than the promise-based one: at D18 the alternative "state a +precondition that no release is cut in the window" was offered and rejected in favor of a structural fix, and leaving a +window at 3→4 would have quietly reinstated the option already thrown out. + +**Resolved by:** user input (option: make Steps 3+4 one unit), on evidence. + +**Raised in round:** R1 + +**Changed in plan:** Primary flow (binding constraints, Step 3, Step 4), Alternate flows and states, Coordinations, +Summary + +**Changed in tech-notes:** — + +### F30: The repaired release can detect a missing target but cannot create one + +**Agent:** `devops-engineer` (DOR-012) + +**Category:** behavioral commitment the step does not deliver + +**Finding.** The spec treated "updates all four targets" as one capability. The release has two, and only one existed. +D5 changes what the release can see; it did not give it the ability to create membership. For a brand-new plugin — +precisely the `han-linear` shape that motivated this work — the repaired release would write nothing to any target, and +the gate would simply stop. The spec sold a tripwire as a repair, and the headline promise would never fire for the one +case the work exists to fix. + +**Evidence considered.** `.claude/skills/han-release/SKILL.md`: + +- `:230` — the version-write step acts only "For **every** plugin whose `target` differs from its `current`". +- `:237-238` — "Skip any plugin whose `target == current` (ahead-path or new plugins — their files are already + correct). When the entire plan is ahead-path/new… this step is a **no-op**." +- `:160-161` — a new child gets "`baseline = current`, `target = current`, **no bump**", so a new plugin always hits + the skip. +- `:235-236` — the only listing write "set[s] the `version` of the `plugins[]` element whose `name` equals the plugin + name". It sets a version on an existing element and never creates one. No behavior is defined when no element exists. + +**Resolution.** Step 3 commits to creation as well as detection. A release creates a missing listing entry or version +record rather than only refusing to proceed. This required a new decision the spec had not made: what version a target +gets when the release did not bump that plugin. Recorded as D31 — a created record is written at the version the +release is publishing for that plugin, which for an unbumped plugin is its current version, so creation and agreement +resolve in one pass. The gate keeps its meaning: it still fires on gaps creation cannot close (a listing entry with no +plugin behind it, an unparseable manifest, a broken version value). + +**Resolved by:** user input (option: create missing membership too). + +**Raised in round:** R1 + +**Changed in plan:** Outcome, Primary flow (Step 3), Alternate flows and states, Edge cases and failure modes, +Coordinations + +**Changed in tech-notes:** — + +### F31: `han-linear` is a third untrue dependency declaration, and the spec's own deferral trigger has already fired + +**Agent:** `adversarial-validator` (V1), with supporting scope from V5. + +**Category:** counter-evidence to a foundational claim + +**Finding.** Step 5 rested on "Two plugins declare that they need the core plugin and never touch it." The count is +three. `han-linear` declares `han-core`, narrates the dependency in its own description, and grants neither the `Agent` +nor the `Skill` tool — the identical structural signature D13 used to condemn `han-feedback`'s declaration ("it is not +permitted to call other plugins at all, so its claim cannot possibly be true"). + +The error cascades further than the count: + +- The **Outcome** ("Every dependency a plugin declares is one it actually uses") stayed false after Step 5 shipped as + scoped, because `han-linear`'s declaration survived untouched. +- **D8** repoints the tutorial's worked example specifically at `han-linear`'s `han-core` dependency, citing it as "a + structural identity for `han-feedback`'s role". If that dependency is itself decorative, D8 replaces one false claim + in the tutorial with another — the exact defect D8 and D27 exist to eliminate. +- **D25** claims "The sweep came back clean" over manifest descriptions, citing only `han-reporting`'s and + `han-feedback`'s manifests. It never checked `han-linear`'s description, which narrates the same false claim its + `dependencies` array does. The sweep was not clean. +- The **Deferred (YAGNI)** entry names its own reopening trigger as "a third decorative dependency is found." That + trigger is met today, before the spec ships. + +**Evidence considered.** `han-linear/.claude-plugin/plugin.json:5` declares `"dependencies": ["han-core"]`; `:3` +states "Depends on han-core." `grep -rn "han-core" han-linear/` returns only the manifest — zero hits in +`skills/work-items-to-linear/SKILL.md` or its `references/`. That SKILL.md's `allowed-tools` lists +`Read, Write, Edit, Glob, Grep, Bash(find *)` plus nine Linear MCP tools, with no `Agent` and no `Skill`. +`git log -p --follow` shows the declaration present since the plugin's introduction (`d94daa2`) — not a recent +regression. Corroborated by `evidence-based-investigator` claim 2's method, which confirmed the same signature for +`han-reporting` (zero `han-core` references outside its manifest) and `han-feedback` (no `Agent`/`Skill` grant). + +**Resolution.** Step 5 deletes three declarations, not two. Step 6's document scope grows to every location narrating +`han-linear`'s dependency (`CLAUDE.md`, `docs/choosing-a-han-plugin.md`, `CONTRIBUTING.md`), which D7's rule already +reaches definitionally — the gap was that the survey ran against a two-plugin premise. + +Three decisions were corrected downstream, because the error had propagated further than the count: + +- **D13** grows from two declarations to three, with the `han-linear` evidence recorded. Its heading keeps the word + "both" so existing cross-references stay stable, with the correction stated beneath it. +- **D8** repoints the tutorial's opt-in-leaf example from `han-linear` to `han-atlassian`. This was the sharpest edge of + the finding: D8 had chosen `han-linear` as the example of a dependency that is *real*, and it is the opposite. + `han-atlassian` was verified to pass both tests the replacement must pass — it survives this work, and its dependency + is genuinely exercised (`han-atlassian/.claude-plugin/plugin.json:3`: "its wrapper skills run skills from each"). + `han-plugin-builder` was considered and rejected: opt-in and unbundled, but it depends on nothing, so it cannot + illustrate an edge. +- **D25** loses its "the sweep came back clean" claim, which was false. The sweep checked only the manifests of plugins + already suspected, which is precisely how the third declaration stayed hidden. The re-run covers every plugin's + description. + +The deferred dependency-usage check stays deferred: its trigger fired, but the YAGNI rule asks what evidence supports +building the mechanism now, and three instances being deleted by hand is still not a case for a general checker. The +trigger is restated so it cannot fire on the instances this work removes. + +**Resolved by:** user input (option: delete it — Step 5 becomes three), on evidence. + +**Raised in round:** R1 + +**Changed in plan:** Outcome, Primary flow (Step 5, Step 6), Deferred (YAGNI) + +**Changed in decision log:** D13, D8, D25 corrected + +**Changed in tech-notes:** — + +### F32: "Roughly twenty releases" is false; the verified count is 11 + +**Agent:** `evidence-based-investigator` (claim 5) and `adversarial-validator` (V2), convergent on the refutation and +**in conflict on the number** — resolved below. + +**Category:** unverified factual claim driving spec prose + +**Finding.** The spec states the drift "went unnoticed across roughly twenty releases" (Step 3) and "persisted for +twenty releases" (Deferred (YAGNI)). Both are false. The claim traces to D12's rationale, which states it with no +`Evidence:` citation for the number itself, and it survived into the spec without ever being verified against a +repository fully capable of answering it. + +**Evidence considered.** The codex records were created at `fabde07` ("feat: add Codex plugin scaffolding", +2026-06-10) and the version content has been frozen since; the only later touch (`d94daa2`) is a pure rename that +leaves the `version` line untouched. + +The two agents disagreed: `evidence-based-investigator` said 11, `adversarial-validator` said 14. Resolved by direct +re-verification in favor of 11, the ancestry-based count: + +- `git merge-base --is-ancestor fabde07 <tag>` over all tags → 11 tags: `v4.0.0, v4.1.0, v4.2.0, v4.3.0, v4.3.1, + v4.3.2, v4.3.3, v4.4.0, v4.5.0, v4.5.1, v4.6.0`. +- `adversarial-validator` counted tags *dated* after 2026-06-10, which adds `v3.3.1`, `v3.4.0`, and `v3.4.1`. Direct + check: none of those three contain `fabde07`. They were cut from branches without the codex scaffolding, so those + releases could not have updated records that did not exist in their tree. + +Ancestry is the defensible test, so the count is 11. The qualitative point the number was serving — nothing asked the +question, at any count — survives at 11 and does not depend on the number. + +**Resolution.** Replaced with the verified count in both locations, cited as codebase evidence. + +**Resolved by:** evidence. + +**Raised in round:** R1 + +**Changed in plan:** Primary flow (Step 3), Deferred (YAGNI) + +**Changed in tech-notes:** — + +### F33: D6 exempts the bundle from version agreement on a rationale that is factually wrong + +**Agent:** `devops-engineer` (DOR-014) + +**Category:** edge-case rule resting on a false premise + +**Finding.** D6's stated reason for exempting the bundle from the version-agreement rule is "a plugin published on one +channel has nothing to disagree with." That is false on disk. The bundle publishes a version in **two** records — +channel one's storefront listing and its own manifest — and D20 deliberately brought that exact pair into the rule +("channel one's listing-versus-manifest version pair is hand-synced by the release today and nothing verifies it"). +D6 and D20 collide, and the Edge-cases row stated the exemption unqualified. + +The bundle is the worst possible plugin to exempt. `docs/semantic-versioning.md:160-172` and +`.claude/skills/han-release/SKILL.md:148-149`: "The parent always bumps on **every** release," so its listing-vs-manifest +hand-sync runs more often than any child's. `SKILL.md:54`: "The release tag is `v{parent target}`" — the bundle's +version names the release. If one release's hand-sync misses the parent, the tag says one version, the manifest says +that version, and the storefront advertises the previous one, silently and permanently, because the rule was told not +to look. That is the D10 drift class, on the one plugin the whole suite is tagged by, introduced by the fix. + +**Evidence considered.** `.claude-plugin/marketplace.json` → `han` 4.6.0; `han/.claude-plugin/plugin.json` → 4.6.0. +Two records, both publishing a version, currently agreeing. + +**Resolution.** The exemption is split. The bundle is exempt from the **cross-channel** comparison, because it has no +channel-two record to compare against, and is **not** exempt from channel one's internal listing-versus-manifest +agreement. D6's rationale sentence is replaced; the Channels-and-targets bullet and the Edge-cases row both carry the +qualifier. + +**Resolved by:** evidence. + +**Raised in round:** R1 + +**Changed in plan:** Channels and targets, Edge cases and failure modes + +**Changed in tech-notes:** — + +### F34: Step 1 manufactures the drift Step 4 repairs, and D22's "dissolves the coupling" overclaims + +**Agent:** `junior-developer` (JD-003), with `adversarial-validator` (V3) tracing the same mechanism to Step 1's own +durability. + +**Category:** ordering hazard + +**Finding.** Step 1 claims its new version record "is correct on arrival rather than created wrong for a later step to +repair," and D22 claims it "dissolves the coupling rather than managing it." Step 1 must create +`han-linear/.codex-plugin/plugin.json` — a file inside `han-linear/`. `docs/semantic-versioning.md:118` reads "A child +bumps only when its own directory changed," and `:166` lists "new files" as a minor bump. So Step 1's own work forces +`han-linear` to bump at the next release, and a release cut before the repair writes only channel one — moving channel +one's version while the record Step 1 just created stays put. Step 1 manufactures the drift for Step 4 to repair. + +This is F1's argument applied to the step F1 did not examine. F1 spotted that Step 4 writes channel-two versions ahead +of the repair and reordered; it did not notice Step 1 also writes a channel-two version record, and Step 1 is still +ahead of the repair. D22's "dissolved" holds only if no release is cut between Step 1 and Step 3 — the promise-based +fix already rejected at D18. + +**Evidence considered.** `find han-linear -iname "*.codex*"` returns nothing, confirming Step 1 must create the +directory and file. `docs/semantic-versioning.md:118,166` supply the bump rule. + +**Resolution.** Largely absorbed by F29's structural fix: with Steps 3 and 4 as one unit, the repair lands before the +version correction, and Step 1's manufactured drift is one more gap that unit clears. What does not absorb is D22's +claim, which is corrected — the coupling is not dissolved, it is small and healed by the Step 3+4 unit. Step 1 carries +the caveat that its record is durable only once that unit lands. + +**Resolved by:** evidence. + +**Raised in round:** R1 + +**Changed in plan:** Primary flow (Step 1) + +**Changed in tech-notes:** — + +### F35: Step 1's install-succeeds promise is unhedged against Open item 2, while the parallel claim is hedged + +**Agent:** `junior-developer` (JD-004) + +**Category:** behavioral commitment resting on a recorded unknown + +**Finding.** Step 1 states "the documented install command succeeds" and Outcome bullet 1 states "Following the +documented install instructions produces a working install, not an error." Both are flat. Open item 2 records that +which revision each channel's client resolves from — branch or release tag — is unknown, and that if it is the tag, +"steps 1 and 4 reach users at the next release rather than on merge." + +The spec treats one class of unknown two ways. Outcome bullet 2 (update prompts) **is** hedged, with a pointer to T1 +and Open item 1. Bullet 1 gets no hedge, for the same reason, from the same kind of external-client unknown. Open item +2 names Step 1 explicitly, and Step 1's text ignores it. + +This compounds with F29: if clients resolve from tags, Step 1's fix reaches users only at the next successful release, +and no release succeeds until the Step 3+4 unit lands. That weakens D11's rationale, which rejected "fix the release +process first" because it "makes the live user-facing error wait on the largest step in the plan" — under tag +resolution the error waits for the repair regardless of Step 1's position. + +**Resolution.** Step 1 and Outcome bullet 1 are hedged against Open item 2 the same way bullet 2 is hedged against +Open item 1. D11's rationale is noted as contingent on Open item 2 resolving to "branch." + +**Resolved by:** evidence. + +**Raised in round:** R1 + +**Changed in plan:** Outcome, Primary flow (Step 1), Open items + +**Changed in tech-notes:** — + +### F36: D26's remedy is unimplementable for the two documents it most affects + +**Agent:** `junior-developer` (JD-005) + +**Category:** resolution that does not survive contact with its targets + +**Finding.** D26 requires corrected documents to "state the dependency rule and point at the manifests as the record, +rather than restating the manifests' contents in prose," and rejects enumeration outright. D26 was reasoned entirely +from one sentence shape — `CONTRIBUTING.md:128`'s universal "Every plugin depends on `han-core`" — where "each plugin +declares its own dependencies; read the manifest" genuinely replaces it. F10 then extended it to "corrected documents" +plural, including the orientation document. + +It does not survive. There is no rule that generates the real dependency graph; it is irregular by design +(`han-planning` depends on `han-core` but not `han-communication`; `han-coding` depends on both; `han-communication` and +`han-plugin-builder` depend on nothing). `docs/concepts.md` and `CLAUDE.md`'s project map exist precisely to narrate +that irregular graph to a new reader or an agent. The only D26-compliant edit is to delete the narration and say "go +read the manifests," which guts the document whose job is orientation — and for `CLAUDE.md`, defeats the point of an +agent-facing map, since agents read it instead of eleven manifests. The natural fix (drop the count, keep the +enumeration) is the one D26 forbids. + +A second, milder problem: Step 6 asserts "hardcoded counts are already a violation of this repository's own +convention." The convention in `CLAUDE.md` § Conventions is scoped to *indexes* ("Verify the indexes list every entity +when editing them, rather than tracking a running total"). Stretching it to prose topology narration is defensible in +spirit but was asserted as settled fact. + +**Resolution.** D26 is scoped to the document shape it was reasoned from — universal claims about the dependency graph. +For documents that legitimately enumerate an irregular graph, Step 6 drops the count, keeps the enumeration, and points +at the manifests as the record. The convention claim is softened to what the convention actually says. + +**Resolved by:** evidence. + +**Raised in round:** R1 + +**Changed in plan:** Primary flow (Step 6) + +**Changed in tech-notes:** — + +### F37: The already-false-neighbor rule is undefined, and there is a live instance inside a passage Step 6 must edit + +**Agent:** `junior-developer` (JD-006) + +**Category:** ambiguity in a scoping rule + +**Finding.** The spec resolves the already-false-document case three incompatible ways. D7 and Out of scope keep such +documents **out** ("the editor is already open" is convenience, not evidence). D9 pulls `CONTRIBUTING.md:128` **in**, +already-false, because "the claim is false, and contributors read it." D27 pulls the tutorial's version promise **in**, +already-false, because "D8 rewrites this exact paragraph." Two inclusion rules, neither generalized, both contradicting +the exclusion rule. + +There is a live instance. `docs/concepts.md:222-223` states the `han` meta-plugin "depends on `han-core`, +`han-planning`, `han-coding`, `han-github`, and `han-reporting`" — the real manifest is +`["han-communication", "han-core", "han-planning", "han-coding", "han-github", "han-reporting"]`, omitting +`han-communication`. Already false, not falsified by this work, and sitting directly between two lines Step 6 must +edit. The implementer corrects line 221, stares at line 222, and the spec does not say which rule applies. + +**Resolution.** The rule is stated once, generalizing what D9 and D27 already did: a false statement inside a passage +this work is already rewriting is corrected; a false statement elsewhere is not. This keeps D7's exclusion intact for +distant staleness and gives Step 6 an answer. + +**Resolved by:** evidence. + +**Raised in round:** R1 + +**Changed in plan:** Primary flow (Step 6) + +**Changed in tech-notes:** — + +### F38: The gate's stated recovery loops against the release's own clean-tree precondition + +**Agent:** `devops-engineer` (DOR-015) + +**Category:** unverified recovery path + +**Finding.** The spec gives two different failure cases the same recovery: "Recovery is discarding local changes" (gate +stop) and "Recovery is discarding local changes and re-running" (partial write). The release hard-stops on a dirty tree +— `.claude/skills/han-release/SKILL.md:72-74`: "**Working tree must be clean.**… This is a hard stop, not a pause +gate." So for a gate stop the loop is: gate stops → discard local changes → the gap is still there → re-run → identical +stop. The stated recovery is a no-op for the case it names. + +With no bypass (D28, correctly), the available move under release pressure is to hand-edit the targets and force +through — shipping around the gap, which is the behavior the entire feature exists to end, performed by the person the +gate was built to protect. + +**Resolution.** Severity drops given F30's answer: once the release creates missing membership, the gate's firing cases +shrink to gaps creation cannot close, so the loop is rare rather than routine. It does not vanish, so Alternate flows +now carries two recoveries instead of one: a partial write is discarded and re-run; a gate stop requires the gap to be +corrected and committed before the release can re-run, because the release refuses to start dirty. This does not reopen +D28. + +**Resolved by:** evidence. + +**Raised in round:** R1 + +**Changed in plan:** Alternate flows and states + +**Changed in tech-notes:** — + +### F39: The gate's window contains a human authorization prompt, and "forced from both sides" overclaims + +**Agent:** `devops-engineer` (DOR-016) + +**Category:** gate ordering + +**Finding.** D24 claims the gate's placement is "forced from both sides." It is forced on the early side and merely +bounded on the late side. The window between the target writes and the commit contains three steps, including +`.claude/skills/han-release/SKILL.md:317-318`, which uses `AskUserQuestion` to ask the operator "publish now?". The +spec's sentence permits the gate anywhere in that window, including after that prompt — meaning the release could show +the maintainer a fully prepared release, ask for approval, receive it, and then refuse. The spec names one boundary +(irreversibility); the release has a second (operator authorization) the spec did not name. + +The cost is bounded — nothing irreversible — but it teaches distrust of the gate, which is D1's failure mode arriving by +a different road. + +**Resolution.** The gate is anchored to both boundaries: it runs on the state being released, before the release asks +the operator to approve publishing, and before anything irreversible. D24's "forced from both sides" is corrected to +name the second boundary rather than assert a symmetry that does not hold. + +**Resolved by:** evidence. + +**Raised in round:** R1 + +**Changed in plan:** Primary flow (Step 3), Coordinations + +**Changed in tech-notes:** — + +### F40: A manifest that fails to parse silently vanishes from the derived plugin list + +**Agent:** `edge-case-explorer` (EC1) + +**Category:** silent failure reproducing the defect the work exists to close + +**Finding.** Neither D5's derivation nor D24's gate states what happens when a manifest is structurally broken rather +than absent or disagreeing. A plugin whose manifest is unparseable would be silently dropped from the derived list, so +the check would answer "does every plugin appear everywhere it should?" for a plugin set that has already quietly +excluded the broken one. That is invisible by construction — the same shape as the original bug D5 was built to end. + +The spec defines "malformed" as a first-class, detectable, surfaced category for work-items headings (D30, D17) and +never once for plugin manifests. + +**Evidence considered.** `.claude/skills/han-release/SKILL.md:37,39` — both `jq` reads swallow parse failures via +`2>/dev/null`, producing empty output rather than an error. This is the established idiom in the very skill D5 extends, +so the failure is not hypothetical: it is the default behavior of the code the derivation grows out of. The trigger is +ordinary — a hand-editing slip during Steps 1 or 4, or an interrupted write from the release's own target-writing step. + +**Resolution.** Symmetry with the work-items domain is not the argument (that would be the reasoning this spec +rejects); the argument is that the failure is silent and reproduces the motivating defect. An unparseable manifest is a +surfaced, blocking failure, named like any other gap, never a plugin the derivation quietly drops. Added to Edge cases. + +**Resolved by:** evidence. + +**Raised in round:** R1 + +**Changed in plan:** Edge cases and failure modes + +**Changed in tech-notes:** — + +### F41: Version agreement has no defined behavior for a present-but-broken version value + +**Agent:** `edge-case-explorer` (EC2) + +**Category:** boundary of a defined rule + +**Finding.** D20's rule covers three records, and the Edge-cases table has rows for "missing from any target" and +"version records disagree." Neither states which bucket a present record with a broken value falls into — a missing +`version` key, an empty string, or a non-version value. A naive equality comparison would treat two empty or missing +values as **agreeing**, which is a false negative on exactly the class D20 was written to catch. + +**Evidence considered.** D20's own evidence establishes the class is not hypothetical: the release hand-syncs channel +one's manifest version into its listing entry, unverified, which D20 names as "a hand-sync of exactly the class that +drifted on channel two." The same manual edit that drifted channel two can produce a broken value rather than a +different one. + +**Resolution.** A record that publishes a version but whose value is empty, missing, or unreadable as a version fails +the check by name, exactly as a genuine disagreement does. It is never treated as vacuously agreeing. Added to Edge +cases. + +**Resolved by:** evidence. + +**Raised in round:** R1 + +**Changed in plan:** Edge cases and failure modes + +**Changed in tech-notes:** — + +## Minor edits + +- F42: Open item 4 is parked with no dependent step, which is the same shape F22 used to remove the old open item 3 — + the spec applies one test in two directions. — `junior-developer` (JD-007) — Open items (item 4 restated as homeless + and kept deliberately, with F22's rule restated rather than contradicted) +- F43: D29's evidence leans on the `d94daa2` atomic-rename precedent, which undercuts itself — that commit updated the + listings in the same commit, so the dangling-entry state has never existed here. The behavior is free (it is the + mirror of the comparison D19 already requires) and is kept; the evidence wording is corrected to say so rather than + lean on a precedent that demonstrates the opposite. — `adversarial-validator` (V4) — Edge cases (D29 evidence + wording; behavior unchanged) diff --git a/docs/plans/han-publishing-cleanup/artifacts/review-iteration-history.md b/docs/plans/han-publishing-cleanup/artifacts/review-iteration-history.md new file mode 100644 index 00000000..a368dabd --- /dev/null +++ b/docs/plans/han-publishing-cleanup/artifacts/review-iteration-history.md @@ -0,0 +1,83 @@ +# Review Iteration History: Han Publishing Cleanup + +Spec: [../feature-specification.md](../feature-specification.md) · Findings: +[review-findings.md](review-findings.md) · Decisions: [decision-log.md](decision-log.md) · Technical notes: +[feature-technical-notes.md](feature-technical-notes.md) + +## R1 + +**Mode:** team + +**Spec-aware mode:** engaged. Detected by filename (`feature-specification.md`) and confirmed by the canonical headings. +Roster excluded the mechanic-level specialists per the spec-stage rules; every agent received the behavioral-level +brief, the YAGNI brief, and the evidence brief. + +**Size:** large — the work spans the release process, the pull-request pipeline, both marketplace manifests, three +plugin manifests, the work-items publisher, and six or more documents. Round cap 3, team cap 5. + +**Specialists engaged:** `junior-developer`, `adversarial-validator`, `evidence-based-investigator`, `devops-engineer`, +`edge-case-explorer`. All five returned output. + +`evidence-based-investigator` was included over the codebase-claims heuristic's literal reading: the spec body contains +no file paths at all, being deliberately channel-neutral, but its entire factual foundation is repository-state claims +expressed in neutral vocabulary. That judgment paid — it refuted two claims the spec asserted as settled. + +**Findings raised:** F28, F29, F30, F31, F32, F33, F34, F35, F36, F37, F38, F39, F40, F41 (major); F42, F43 (minor). +Numbering continues from [team-findings.md](team-findings.md), which reached F27. + +**Convergence.** Three agents independently reached F28 (the check cannot block) from different angles: the +junior-developer by asking what enforces the guarantee, the devops-engineer by auditing the enforcement surface, and +the evidence-based-investigator by trying to close Open item 3. Convergence from three unrelated starting points on a +claim the prior round had certified is why it is treated as settled rather than plausible. + +**Disagreement resolved.** `evidence-based-investigator` and `adversarial-validator` both refuted "roughly twenty +releases" but returned different counts — 11 and 14. Resolved to **11** by direct re-verification rather than by +preferring an agent: the 14 came from counting tags *dated* after the codex records were created, and the three extra +tags (`v3.3.1`, `v3.4.0`, `v3.4.1`) do not contain the creating commit, having been cut from branches without the codex +scaffolding. Ancestry is the defensible test. Recorded on F32. + +**Surfaced to the user.** Four findings required judgment only the plan's author could make and were surfaced with +impact, trade-offs, and a recommendation rather than resolved silently: + +- F30 — does the repaired release create missing membership, or only detect it? **User chose creation**, over the + recommendation of detection-only. This is the larger capability and forced a new decision (D31) on what version a + created record carries. +- F28 — own the enforcement, or state the guarantee honestly per surface? **User chose the honest downgrade** (the + recommendation). Open item 3 converts from an unknown to an unmade decision. +- F31 — delete the third untrue declaration, reopen the deferred checker, or leave it out of scope? **User chose + deletion** (the recommendation), without reopening the checker. +- F29 — close the release-freeze window structurally, by precondition, or by weakening the gate? **User chose the + structural fix** (the recommendation): steps 3 and 4 become one unit. + +**Changed in plan:** Channels and targets; Outcome; Actors and triggers; Primary flow (binding constraints, Step 1, +Step 3, Step 4, Step 5, Step 6, Step 7); Alternate flows and states; Edge cases and failure modes; Coordinations; User +interactions; Out of scope; Deferred (YAGNI); Open items; Summary; Review History (new). + +**Changed in decision log:** D31, D32, D33, D34, D35 added. Seven existing decisions corrected: + +- **D6** — its exemption rationale ("a plugin published on one channel has nothing to disagree with") was factually + wrong; the bundle publishes a version in two records. Exemption split. +- **D8** — its repoint target was the plugin that turned out to carry the third untrue declaration. Moved to + `han-atlassian`, verified to be both surviving and true. +- **D13** — two declarations became three. Heading left intact so cross-references stay stable. +- **D22** — "dissolves the coupling" overclaimed; step 1's own file creation obliges a bump. +- **D25** — "the sweep came back clean" was false; it had only checked already-suspected plugins. +- **D26** — scoped to universal claims; its remedy did not survive contact with an irregular graph. +- **D29** — evidence rewritten; the cited rename precedent undercut itself, so the behavior now stands on being free. + +**Changed in tech-notes:** T2 added — a pull-request check blocks only where a required status check demands it. +Load-bearing (a spec commitment rested on it) and not discoverable from the code repository, since it lives in the +hosting platform's settings rather than in any file here. That is precisely why the gap survived: the repository shows +the trigger, and the trigger looks like enforcement. + +**Stability assessment:** not stable. The round produced 14 major findings, four of which changed behavioral +commitments and two of which falsified foundational claims the prior round had accepted. That is far past the stop rule +(≤2 new findings and zero major). The prior pass was strong — nothing smuggled back into scope, no YAGNI candidate +missed, and its own three rejected findings were correctly rejected — so the volume here reflects that the review +surface moved outward to claims about the world (live platform configuration, release-skill capability, git ancestry) +rather than that the earlier work was weak. + +**Next step:** run R2. The concentration of R1's findings is instructive: the errors clustered where the spec asserted +facts about systems outside its own prose — what the release skill can do, what the platform enforces, how many +releases had run. R2 should verify that R1's own resolutions hold, and specifically stress the newly added D31, whose +creation capability is the largest untested commitment in the spec and was chosen against the recommendation. diff --git a/docs/plans/han-publishing-cleanup/feature-specification.md b/docs/plans/han-publishing-cleanup/feature-specification.md index ea2f7fef..f7001ba0 100644 --- a/docs/plans/han-publishing-cleanup/feature-specification.md +++ b/docs/plans/han-publishing-cleanup/feature-specification.md @@ -7,7 +7,9 @@ Their execution order differs from the source's listing order, deliberately and Companion artifacts: - [Decision log](artifacts/decision-log.md) -- [Team findings](artifacts/team-findings.md) +- [Team findings](artifacts/team-findings.md) — from planning +- [Review findings](artifacts/review-findings.md) and + [review iteration history](artifacts/review-iteration-history.md) — from review - [Technical notes](artifacts/feature-technical-notes.md) ## Channels and targets @@ -27,18 +29,23 @@ document is readable without it. evenly into "two and two". - **A plugin** — a directory the suite ships as an installable unit ([D19](artifacts/decision-log.md#d19-a-plugin-and-the-targets-it-belongs-in-are-defined-positively)). -- **The bundle** — the meta-plugin that installs the suite in one command. It exists on channel one only +- **The bundle** — the meta-plugin that installs the suite in one command. It exists on channel one only. It is + therefore exempt from the comparison **between** channels, having no channel-two record to disagree with — but it + publishes a version in **both** of channel one's records, so it is not exempt from their agreement with each other. + It bumps on every release and its version names the release tag, which makes it the most frequently hand-synced pair + in the suite rather than the least ([D6](artifacts/decision-log.md#d6-the-bundle-is-a-permanently-named-exception-on-the-second-channel)). ## Outcome -Han is published completely and honestly to both of the places it ships to, and a check makes it impossible to quietly -stop being so. +Han is published completely and honestly to both of the places it ships to, and no release can quietly stop it being +so. Concretely, when this work is done: - Every Han plugin advertised as installable on a channel is actually installable on that channel. Following the - documented install instructions produces a working install, not an error. + documented install instructions produces a working install, not an error — reaching people either on merge or at the + next release, depending on what each channel's client resolves from ([Open item 2](#open-items)). - People who installed Han on channel two are offered updates again, once the mechanism this rests on is confirmed ([T1](artifacts/feature-technical-notes.md#t1-update-availability-on-channel-two-is-decided-by-the-published-version-number), [Open item 1](#open-items)). @@ -49,9 +56,16 @@ Concretely, when this work is done: - **Every document that describes Han's release procedure describes the procedure that exists**, so a contributor following the contributor guide is not blocked by the check for doing what the guide told them ([D21](artifacts/decision-log.md#d21-the-release-procedure-documents-are-owned-by-the-step-that-falsifies-them)). -- The release process starts from what is really in the repository, covers every target, and stops rather than shipping - around a gap ([D5](artifacts/decision-log.md#d5-the-check-derives-the-plugin-list-from-the-repository)). -- A check enforces all of the above on every change, and it is green from the day it lands +- The release process starts from what is really in the repository, brings every target up to date — creating the + records a plugin is missing, not only reporting them — and stops rather than shipping around a gap it cannot close + ([D5](artifacts/decision-log.md#d5-the-check-derives-the-plugin-list-from-the-repository), + [D31](artifacts/decision-log.md#d31-the-release-creates-a-missing-target-at-the-version-it-is-publishing)). +- The rule is enforced where it can be enforced, and the spec says where that is rather than implying it is everywhere. + A release **refuses to proceed**; a pull request gets a **visible failure a person can still merge past**, because + nothing in this repository makes a check blocking + ([T2](artifacts/feature-technical-notes.md#t2-a-pull-request-check-blocks-a-merge-only-where-a-required-status-check-demands-it), + [D32](artifacts/decision-log.md#d32-the-guarantee-is-stated-per-surface-because-only-the-release-can-refuse)). The + check is green from the day it lands ([D1](artifacts/decision-log.md#d1-the-check-lands-last-because-a-check-that-blocks-everything-gets-disabled)). ## Actors and triggers @@ -59,7 +73,7 @@ Concretely, when this work is done: | Actor | Trigger | What they need from this feature | | ------------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------- | | Han maintainer | Works through the seven steps once | Each step lands without breaking the next; the real ordering constraints are stated | -| Han maintainer | Cuts any future release | The release covers every target and refuses to ship around a missing plugin | +| Han maintainer | Cuts any future release | The release brings every target up to date, creating what is missing, and refuses to ship around a gap it cannot close | | Contributor | Opens a pull request that adds or renames a plugin | The check tells them what is missing, and the contributor guide already told them the four targets | | Channel-two installer | Runs the documented install command for any advertised plugin | The install succeeds | | Channel-two installer | Has Han installed and expects updates | Updates are offered when releases happen | @@ -70,8 +84,19 @@ Concretely, when this work is done: Seven steps, executed in the order below. Only some adjacencies are forced; the forced ones are named and the rest are free ([D18](artifacts/decision-log.md#d18-the-execution-order-is-a-partial-order-and-the-repair-precedes-the-correction)). -The binding constraints are: **the check is last**; **the release repair precedes the version correction**; **the -declaration deletion and the document correction are one unit**; and the work-items fix is independent of all of it. +The binding constraints are: **the check is last**; **the release repair and the version correction are one unit**; +**the declaration deletion and the document correction are one unit**; and the work-items fix is independent of all of +it. + +The first unit is not merely an ordering. The release gate begins refusing releases the moment the release runs the +rule, which is step 3 — not step 7, where the rule merely becomes visible on pull requests +([D14](artifacts/decision-log.md#d14-the-release-runs-the-check-rather-than-restating-it)). Every plugin whose version +records disagree is a gap the gate names, and every plugin but one disagrees until step 4 corrects them. Shipping step 3 +without step 4 therefore stops every release in between, which is the same "a blocking rule arrives before the problems +are fixed" failure the check is sequenced last to avoid +([D1](artifacts/decision-log.md#d1-the-check-lands-last-because-a-check-that-blocks-everything-gets-disabled)). The two +steps land together, and the window does not exist +([D18](artifacts/decision-log.md#d18-the-execution-order-is-a-partial-order-and-the-repair-precedes-the-correction)). ### Step 1: Publish the Linear plugin to channel two @@ -81,12 +106,22 @@ The Linear plugin is advertised in channel two's setup instructions and was neve those instructions gets an error today. After this step, the Linear plugin appears in channel two's storefront listing and carries its own version record, so -the documented install command succeeds. Its new version record is created at the version channel one already publishes -for it, so it is correct on arrival rather than created wrong for a later step to repair +the documented install command succeeds — for users, either on merge or at the next release, depending on what that +channel's client resolves from ([Open item 2](#open-items)). Its new version record is created at the version channel +one already publishes for it, so it is correct on arrival ([D22](artifacts/decision-log.md#d22-the-new-version-record-is-created-at-the-plugins-channel-one-version)). +Correct on arrival is not the same as durable. This step adds a file inside the Linear plugin's own directory, which is +exactly what obliges that plugin to bump at the next release — and until step 3 lands, a release moves the plugin's +channel-one version without touching the channel-two record this step just created. Step 1 manufactures one instance of +the very drift step 4 repairs. The coupling is not dissolved; it is small, and the step 3 and 4 unit heals it along +with the other eight. + This goes first because it is the only step where a person following the project's own instructions hits an error right -now ([D11](artifacts/decision-log.md#d11-step-1-goes-first-because-it-is-a-live-broken-promise)). +now ([D11](artifacts/decision-log.md#d11-step-1-goes-first-because-it-is-a-live-broken-promise)). That reasoning holds +on the assumption that channel two's client resolves from the default branch. If it resolves from the release tag, this +step reaches nobody until the next successful release, which the step 3 and 4 unit gates anyway — and going first buys +ordering clarity rather than a faster fix ([Open item 2](#open-items)). ### Step 2: Close the GitHub publisher's silent hole @@ -115,24 +150,39 @@ dependency ([D23](artifacts/decision-log.md#d23-step-2-is-a-distinct-concern-ret ### Step 3: Teach the release process about every target -_Source position: 6. Moved ahead of the version correction — see +_Source position: 6. Moved ahead of the version correction, and shipped together with it — see [D18](artifacts/decision-log.md#d18-the-execution-order-is-a-partial-order-and-the-repair-precedes-the-correction)._ The release process updates two of the four targets, and takes its list of plugins from one of the targets it also writes. It therefore cannot detect that the other two have fallen behind, which is why the drift went unnoticed across -roughly twenty releases. +eleven releases. After this step, a release derives the set of plugins from what is actually in the repository -([D5](artifacts/decision-log.md#d5-the-check-derives-the-plugin-list-from-the-repository)) and updates all four targets. - -A release refuses to proceed when a plugin is missing from a target it belongs in, or when a plugin's version records -disagree. **The gate runs after the release has written all four targets and before it commits, tags, pushes, or -publishes anything** — early enough that every action after it is still local and reversible, late enough to judge the -state actually being released rather than the state before it +([D5](artifacts/decision-log.md#d5-the-check-derives-the-plugin-list-from-the-repository)) and brings all four targets +up to date. + +Bringing a target up to date includes **creating a record that does not exist yet**, not only updating one that has +fallen behind. This distinction is the difference between a release that repairs and a release that merely complains: +today's process can only write a version onto a record already present, so a plugin absent from a target stays absent +no matter how many releases run. A record created this way is written at the version the release is publishing for that +plugin, which for a plugin the release did not bump is the version it already has — so the record arrives correct and +agreeing rather than arriving and then needing a second pass +([D31](artifacts/decision-log.md#d31-the-release-creates-a-missing-target-at-the-version-it-is-publishing)). + +A release refuses to proceed when it finds a gap it cannot close: a listing naming a plugin that does not exist, a +record it cannot read, or a version it cannot make sense of. **The gate runs on the state being released — after the +release has brought all four targets up to date, before it asks anyone to approve publishing, and before it commits, +tags, pushes, or publishes anything.** Early enough that every action after it is still local and reversible; late +enough to judge what is actually being released rather than what preceded it; and before the person is asked to +authorize, because a gate that refuses after approval teaches people to distrust it ([D24](artifacts/decision-log.md#d24-the-gate-runs-after-all-targets-are-written-and-before-anything-irreversible)). When it stops, it names every gap it found rather than the first ([D12](artifacts/decision-log.md#d12-a-missing-plugin-stops-the-release-and-every-gap-is-named)). +This is where the rule starts refusing things. That is why this step and step 4 are one unit: every plugin but one has +version records that disagree until step 4 corrects them, and a gate that is live against nine gaps stops every release +until they are closed. + The release holds no copy of the rule. It runs the check and reports what the check says ([D14](artifacts/decision-log.md#d14-the-release-runs-the-check-rather-than-restating-it)). @@ -140,13 +190,16 @@ This step also corrects the documents that describe the release procedure, becau ([D21](artifacts/decision-log.md#d21-the-release-procedure-documents-are-owned-by-the-step-that-falsifies-them)). One exception is permanent and named: the bundle cannot be published to channel two, because that channel does not -support bundles. The rule knows this about that one plugin specifically and neither flags its absence nor applies the -version-agreement rule to it, since a plugin on one channel has nothing to disagree with -([D6](artifacts/decision-log.md#d6-the-bundle-is-a-permanently-named-exception-on-the-second-channel)). +support bundles. The rule knows this about that one plugin specifically and does not flag its absence, nor ask it to +agree with a channel-two record it does not have. The exception stops there. The bundle publishes a version in both of +channel one's records, so those two are held to agreement like any other plugin's — and more carefully than most, since +the bundle bumps on every release and the release tag is named after its version +([D6](artifacts/decision-log.md#d6-the-bundle-is-a-permanently-named-exception-on-the-second-channel), +[D20](artifacts/decision-log.md#d20-version-agreement-covers-every-record-that-publishes-a-version)). ### Step 4: Correct the frozen version numbers -_Source position: 3. Moved after the release repair._ +_Source position: 3. Moved after the release repair, and shipped together with it as one unit._ Channel two's published version numbers have not moved since the day they were created, so that channel never offers anyone an update @@ -155,21 +208,31 @@ anyone an update After this step, each plugin's channel-two version matches the version channel one publishes for that same plugin ([D10](artifacts/decision-log.md#d10-the-two-channels-publish-one-version-per-plugin)). -Because the release process was repaired first, this correction is durable: the very next release keeps it correct -rather than re-freezing it. Had this step come first, any release cut before the repair would have undone it +Because the release process is repaired first, this correction is durable: the very next release keeps it correct +rather than re-freezing it. Had this step come first, any release cut before the repair would have undone it. And +because the repair ships together with this step rather than merely ahead of it, the reverse window does not open +either — the gate never spends a release refusing the disagreements this step is on its way to fix ([D18](artifacts/decision-log.md#d18-the-execution-order-is-a-partial-order-and-the-repair-precedes-the-correction)). -### Step 5: Delete the two untrue dependency declarations +This step closes nine gaps, not eight. Eight plugins carry a channel-two version that has fallen behind; the ninth is +the Linear plugin, whose record step 1 created and whose own arrival obliged it to bump. + +### Step 5: Delete the three untrue dependency declarations _Source position: 4. One unit with step 6._ -Two plugins declare that they need the core plugin and never touch it. The reporting plugin's use moved elsewhere and -the declaration was left behind. The feedback plugin is not permitted to invoke other plugins at all, so its declaration -cannot be true. +Three plugins declare that they need the core plugin and never touch it. The reporting plugin's use moved elsewhere and +the declaration was left behind. The feedback plugin and the Linear plugin are not permitted to invoke other plugins at +all, so their declarations cannot be true — neither is granted the means to call one. + +The source plan named two. The third was found during review, and it matters beyond arithmetic: the Linear plugin was +the example the tutorial's rewrite planned to point at as a dependency that is real +([D8](artifacts/decision-log.md#d8-the-tutorials-worked-example-repoints-to-surviving-real-edges)). Repointing a lesson +about true dependencies at a false one would have reproduced the defect the rewrite exists to remove. -After this step, the reporting plugin declares only what it uses, and the feedback plugin declares nothing. Installing -either no longer drags in a large plugin the installer will never use, and every remaining declaration in the suite is -one the declaring plugin actually uses +After this step, the reporting plugin declares only what it uses, and the feedback and Linear plugins declare nothing. +Installing any of them no longer drags in a large plugin the installer will never use, and every remaining declaration +in the suite is one the declaring plugin actually uses ([D13](artifacts/decision-log.md#d13-both-untrue-declarations-are-deleted-rather-than-made-true)). ### Step 6: Correct every document the declaration deletion falsifies @@ -184,64 +247,117 @@ declaration deletion falsifies. Step 3 already owns the ones the release repair ([D21](artifacts/decision-log.md#d21-the-release-procedure-documents-are-owned-by-the-step-that-falsifies-them)). After this step, no document describes a dependency the suite does not have. That includes the contributor guide, the -orientation document new readers are told to read first, the canonical long-form doc for the affected plugin, and the -tutorial that teaches plugin dependencies. It also includes one document that already described a topology the suite -never had, found while surveying the others +orientation document new readers are told to read first, the canonical long-form docs for the affected plugins, the +plugin-selection guide, the agent-facing project map, and the tutorial that teaches plugin dependencies. It also +includes one document that already described a topology the suite never had, found while surveying the others ([D9](artifacts/decision-log.md#d9-the-already-false-contributor-claim-is-in-scope)). +The survey behind this step originally ran against a two-plugin premise and must be re-run against the third +declaration step 5 deletes ([F31](artifacts/review-findings.md#f31-han-linear-is-a-third-untrue-dependency-declaration-and-the-specs-own-deferral-trigger-has-already-fired)). +That is not a change to the rule — the rule already reaches those locations — it is the same rule applied to the +corrected set. + +**Already-false statements sitting inside passages this work rewrites are corrected; already-false statements elsewhere +are not.** This is the rule the spec was applying without stating: it is why the contributor guide's untrue universal +claim is in scope and why the tutorial's untrue version promise is in scope, while the stale enumerations elsewhere in +the repository stay out +([D33](artifacts/decision-log.md#d33-an-already-false-statement-inside-a-rewritten-passage-is-corrected)). One live +instance is known: the orientation document's description of the bundle's own dependencies omits one of them, is not +falsified by this work, and sits between two lines this step must edit. It is corrected, because the editor is not +merely open — the surrounding sentences are being rewritten. + Manifest descriptions are documents and are in scope. Manifest dependency declarations are the record itself, not a description of it, and are step 5's business ([D25](artifacts/decision-log.md#d25-manifest-descriptions-are-documents-the-declarations-are-the-record)). -The corrected documents state the dependency **rule** and point at the manifests as the record, rather than restating -the manifests' contents in prose. Swapping a stale universal claim for a stale enumeration reproduces the same defect -with a longer half-life, and hardcoded counts are already a violation of this repository's own convention -([D26](artifacts/decision-log.md#d26-corrected-documents-state-the-rule-and-point-at-the-record)). - -The tutorial that teaches plugin dependencies by walking through the two deleted edges has its worked example repointed -at edges that remain real, keeping its teaching shape -([D8](artifacts/decision-log.md#d8-the-tutorials-worked-example-repoints-to-surviving-real-edges)). Its claim to print -the real on-disk version numbers is dropped rather than repaired, because that claim is already false and keeping it -would create a maintenance obligation nobody asked for +A document making a **universal claim** about the dependency graph states the rule instead and points at the manifests +as the record. Swapping a stale universal claim for a stale enumeration reproduces the same defect with a longer +half-life. + +That remedy applies to universal claims and stops there, because there is no rule that generates this suite's +dependency graph. The graph is irregular on purpose: some plugins depend on the core plugin and not the communication +plugin, some on both, some on nothing. A document whose job is to orient a reader — or an agent that reads one map +instead of eleven manifests — legitimately enumerates it, and "go read the manifests" would delete the document's +reason to exist. Those documents **keep the enumeration, drop any hardcoded count, and name the manifests as the +record**. The distinction is between a claim that is wrong because reality is irregular and a listing that is merely +long ([D26](artifacts/decision-log.md#d26-corrected-documents-state-the-rule-and-point-at-the-record)). Dropping the +count follows this repository's convention that indexes are verified complete rather than counted, applied here by +extension rather than by letter. + +The tutorial that teaches plugin dependencies by walking through the deleted edges has its worked example repointed at +edges that remain real, keeping its teaching shape +([D8](artifacts/decision-log.md#d8-the-tutorials-worked-example-repoints-to-surviving-real-edges)). The replacement +edge must be one this work leaves standing **and** one that is actually true — the plugin the rewrite originally +planned to point at turned out to carry the third untrue declaration, so repointing there would have taught the lesson +using a counter-example to itself. Its claim to print the real on-disk version numbers is dropped rather than repaired, +because that claim is already false and keeping it would create a maintenance obligation nobody asked for ([D27](artifacts/decision-log.md#d27-the-tutorial-teaches-shape-and-stops-promising-real-version-numbers)). ### Step 7: Turn on the automated check _Source position: 7. Last, and this is the one hard ordering constraint the source plan argued for._ -Only now does the rule become blocking. The rule itself has existed since step 3, because the release runs it; this step -is what makes it refuse a pull request -([D14](artifacts/decision-log.md#d14-the-release-runs-the-check-rather-than-restating-it)). It verifies that every plugin -in the repository appears in every target it belongs in, and that a plugin's version records agree. +Only now does the rule become **visible on a pull request**. The rule itself has existed since step 3, because the +release runs it, and it has been refusing releases since then +([D14](artifacts/decision-log.md#d14-the-release-runs-the-check-rather-than-restating-it)). This step puts the same +answer in front of a contributor before a maintainer meets it at release time. It verifies that every plugin in the +repository appears in every target it belongs in, and that a plugin's version records agree. It runs on every pull request, and additionally on the machines of contributors who have installed the optional local hooks ([D4](artifacts/decision-log.md#d4-the-check-blocks-on-every-pull-request-and-runs-locally-where-hooks-are-installed)). +**What this step does not do is make the rule blocking, and the spec no longer claims it does.** A pull-request check +prevents a merge only where the hosting platform is configured to require it, and this repository has no such +configuration — the default branch is unprotected and the one ruleset that exists is disabled and carries no +required-check rule, so enabling it would not change the answer either +([T2](artifacts/feature-technical-notes.md#t2-a-pull-request-check-blocks-a-merge-only-where-a-required-status-check-demands-it)). +A red check here is a signal a person can merge past. The surface that actually refuses is the release +([D32](artifacts/decision-log.md#d32-the-guarantee-is-stated-per-surface-because-only-the-release-can-refuse)). + +That is worth having on its own terms: it moves the discovery of a gap from release day to the pull request that +introduced it, which is where it is cheapest to fix and where the person who caused it is still holding the context. +Making it genuinely blocking is a change to repository settings that no step here owns, and the spec names that gap +rather than assuming it away ([Open item 3](#open-items)). + Because steps 1 through 6 have already landed, the check is green on the day it arrives and stays green -([D1](artifacts/decision-log.md#d1-the-check-lands-last-because-a-check-that-blocks-everything-gets-disabled)). Every -problem above becomes impossible to reintroduce. +([D1](artifacts/decision-log.md#d1-the-check-lands-last-because-a-check-that-blocks-everything-gets-disabled)). There is no disable switch, deliberately ([D28](artifacts/decision-log.md#d28-the-check-ships-with-no-disable-switch)). ## Alternate flows and states -**The check lands before the problems are fixed.** It fails immediately on nearly every plugin and blocks every release -and pull request from its first day. The check would be correct and the repository would not be ready for it. The -predictable outcome is that someone disables it, at which point it protects nothing and the repository is worse off than -before by one broken thing +**The rule lands before the problems are fixed.** This is the failure the ordering exists to prevent, and it has two +faces. On the release surface it is real and immediate: the rule starts refusing releases at step 3, so shipping step 3 +without step 4 stops every release until the versions are corrected. That is why the two are one unit. On the +pull-request surface the classic version — a correct check that blocks everything on day one until someone disables it +— cannot occur here, because nothing on that surface blocks +([T2](artifacts/feature-technical-notes.md#t2-a-pull-request-check-blocks-a-merge-only-where-a-required-status-check-demands-it)). +The failure mode there is quieter and no less real: a check that is permanently red is a check people learn to scroll +past, and a signal nobody reads protects nothing ([D1](artifacts/decision-log.md#d1-the-check-lands-last-because-a-check-that-blocks-everything-gets-disabled)). -**A release runs while a plugin is missing from a target.** The release stops after writing the targets and before -committing +**A release runs while a plugin is missing from a target.** The release creates the missing record at the version it is +publishing for that plugin and proceeds +([D31](artifacts/decision-log.md#d31-the-release-creates-a-missing-target-at-the-version-it-is-publishing)). This is +the ordinary path, not the failure path: the whole point of the repair is that a release closes this gap rather than +reporting it. + +**A release meets a gap it cannot close.** A listing naming a plugin that is not in the repository, a record it cannot +read, or a version value it cannot make sense of. The release stops after bringing the targets up to date, before +asking anyone to approve, and before committing ([D24](artifacts/decision-log.md#d24-the-gate-runs-after-all-targets-are-written-and-before-anything-irreversible)), naming every gap it found rather than the first ([D12](artifacts/decision-log.md#d12-a-missing-plugin-stops-the-release-and-every-gap-is-named)). Nothing is tagged, -pushed, or published. Recovery is discarding local changes. +pushed, or published. **Recovery is not discarding local changes** — that reproduces the same stop, because the gap is +in the repository and not in the release's uncommitted work. The gap must be corrected and committed, and only then can +the release run again, because a release refuses to start from a dirty tree at all +([D34](artifacts/decision-log.md#d34-a-gate-stop-costs-a-separate-commit-because-the-release-refuses-a-dirty-tree)). **A release fails partway through writing the four targets.** Every write happens before anything irreversible, so the -repository is left with local modifications and nothing published. Recovery is discarding local changes and re-running. -No compensation or rollback machinery is specified because none is needed +repository is left with local modifications and nothing published. Recovery here *is* discarding local changes and +re-running, because nothing in the repository is wrong — only the incomplete local work. No compensation or rollback +machinery is specified because none is needed ([D24](artifacts/decision-log.md#d24-the-gate-runs-after-all-targets-are-written-and-before-anything-irreversible)). **A work-items run meets a file annotated by a different tracker.** The run stops before creating anything and names the @@ -251,24 +367,31 @@ work items whose annotations it does not recognize, so a person decides whether **A work-items run meets a file this same tracker already annotated.** Unchanged from today: those items are recognized as already published, skipped, and reported in the skipped count. A partial run resumes cleanly. -**A new plugin is added after this work.** The check fails on the pull request that adds it until the contributor lists -it in every target it belongs in. This is the intended behavior, and it is only defensible because step 3 corrected the +**A new plugin is added after this work.** The check fails visibly on the pull request that adds it, telling the +contributor which targets it is missing from. If they act on it, the plugin arrives complete. If they merge past it — +which they can — the next release creates the missing records itself +([D31](artifacts/decision-log.md#d31-the-release-creates-a-missing-target-at-the-version-it-is-publishing)), so the +signal being advisory costs lateness rather than correctness. The signal is only fair because step 3 corrected the contributor guide to name all four targets rather than one ([D21](artifacts/decision-log.md#d21-the-release-procedure-documents-are-owned-by-the-step-that-falsifies-them)). **A release is cut from a branch where a step has not landed.** What stops it is the release's own gate, not a pull-request check that may never have run — the release process permits cutting from a non-default branch, and a branch -with no pull request gets no pipeline run -([D24](artifacts/decision-log.md#d24-the-gate-runs-after-all-targets-are-written-and-before-anything-irreversible)). +with no pull request gets no pipeline run. This is the general case rather than a corner: the release gate is the only +surface that refuses anything, so it is the only surface a missing step is caught on +([D32](artifacts/decision-log.md#d32-the-guarantee-is-stated-per-surface-because-only-the-release-can-refuse)). ## Edge cases and failure modes | Case | Behavior | | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | -| The bundle is absent from channel two | Not flagged, and the version-agreement rule does not apply to it ([D6](artifacts/decision-log.md#d6-the-bundle-is-a-permanently-named-exception-on-the-second-channel)) | -| A plugin is missing from any target it belongs in, including just one of two channels | Check fails, naming the plugin and every target it is missing from. This is the shape of the defect that motivated the work ([D19](artifacts/decision-log.md#d19-a-plugin-and-the-targets-it-belongs-in-are-defined-positively)) | -| A plugin is in a storefront listing but not in the repository | Check fails. A listing entry resolving to nothing breaks the install-succeeds promise directly ([D29](artifacts/decision-log.md#d29-a-listing-entry-with-no-plugin-behind-it-fails-the-check)) | +| The bundle is absent from channel two | Not flagged, and the cross-channel comparison does not apply to it ([D6](artifacts/decision-log.md#d6-the-bundle-is-a-permanently-named-exception-on-the-second-channel)) | +| The bundle's two channel-one records disagree with each other | Check fails, like any other plugin. The bundle's exemption is from the comparison between channels, not from agreement within one. It bumps every release and its version names the release tag, so this is the most-exercised pair in the suite ([D6](artifacts/decision-log.md#d6-the-bundle-is-a-permanently-named-exception-on-the-second-channel), [D20](artifacts/decision-log.md#d20-version-agreement-covers-every-record-that-publishes-a-version)) | +| A plugin is missing from any target it belongs in, including just one of two channels | A release creates the missing record and proceeds ([D31](artifacts/decision-log.md#d31-the-release-creates-a-missing-target-at-the-version-it-is-publishing)). On a pull request the check reports it, naming the plugin and every target it is missing from. This is the shape of the defect that motivated the work ([D19](artifacts/decision-log.md#d19-a-plugin-and-the-targets-it-belongs-in-are-defined-positively)) | +| A plugin is in a storefront listing but not in the repository | Check fails. A listing entry resolving to nothing breaks the install-succeeds promise directly, and it is the one membership gap a release must not "fix" by itself, since the remedy is a person deciding whether the plugin or the entry is the mistake ([D29](artifacts/decision-log.md#d29-a-listing-entry-with-no-plugin-behind-it-fails-the-check)) | | A plugin's version records disagree | Check fails, naming the plugin and every disagreeing record ([D20](artifacts/decision-log.md#d20-version-agreement-covers-every-record-that-publishes-a-version)) | +| A record cannot be read at all | Surfaced and blocking, never skipped. A record that fails to parse must not silently drop its plugin from the set being checked — a rule applied to a set that quietly excludes the broken member is the same invisible-by-construction defect this work exists to end ([D35](artifacts/decision-log.md#d35-an-unreadable-record-or-version-is-surfaced-not-skipped)) | +| A record publishes a version, but the value is absent, empty, or not a version | Check fails, naming it, exactly as a disagreement does. Two unreadable values are never treated as agreeing with each other ([D35](artifacts/decision-log.md#d35-an-unreadable-record-or-version-is-surfaced-not-skipped)) | | A plugin has a manifest and no skills | Valid. The bundle is permanently in this state, so "has skills" is not part of what makes a directory a plugin ([D19](artifacts/decision-log.md#d19-a-plugin-and-the-targets-it-belongs-in-are-defined-positively)) | | A work item's heading is malformed in any way the publisher does not recognize | Surfaced, never silently passed over. "Accounted for" means every heading is either published, skipped-and-counted, or surfaced ([D30](artifacts/decision-log.md#d30-accounted-for-is-defined-so-the-promise-is-not-circular)) | | A work-items file mixes annotated and unannotated items for the same tracker | Unchanged: annotated items skipped and counted, unannotated items published | @@ -280,15 +403,22 @@ with no pull request gets no pipeline run - **The release process and the repository.** The release reads the set of plugins from the repository rather than from a target it also writes, so a stale listing can no longer hide a plugin from it ([D5](artifacts/decision-log.md#d5-the-check-derives-the-plugin-list-from-the-repository)). -- **The release process and both storefronts.** The release writes all four targets before it does anything - irreversible - ([D24](artifacts/decision-log.md#d24-the-gate-runs-after-all-targets-are-written-and-before-anything-irreversible)). +- **The release process and both storefronts.** The release brings all four targets up to date — creating what is + missing, updating what is stale — before it does anything irreversible, and before it asks anyone to approve + ([D24](artifacts/decision-log.md#d24-the-gate-runs-after-all-targets-are-written-and-before-anything-irreversible), + [D31](artifacts/decision-log.md#d31-the-release-creates-a-missing-target-at-the-version-it-is-publishing)). - **The check and the release process.** One rule, one bearer. The release runs the check and reports its answer rather than restating the rule in its own words, so the two cannot drift - ([D14](artifacts/decision-log.md#d14-the-release-runs-the-check-rather-than-restating-it)). -- **The check and the pull-request pipeline.** The check blocks on every pull request. Local hooks are optional in this - repository, so the local half is a convenience for contributors who opted in, not a guarantee the feature rests on - ([D4](artifacts/decision-log.md#d4-the-check-blocks-on-every-pull-request-and-runs-locally-where-hooks-are-installed)). + ([D14](artifacts/decision-log.md#d14-the-release-runs-the-check-rather-than-restating-it)). One consequence is easy + to miss: because the release runs the rule, the rule is refusing releases from step 3 onward, four steps before it + appears on a pull request. +- **The check and the pull-request pipeline.** The check runs on every pull request and reports a failure a person can + merge past, because nothing here makes a check required + ([T2](artifacts/feature-technical-notes.md#t2-a-pull-request-check-blocks-a-merge-only-where-a-required-status-check-demands-it)). + Local hooks are optional in this repository, so the local half is a convenience for contributors who opted in. Neither + surface is a guarantee the feature rests on; the release gate is + ([D4](artifacts/decision-log.md#d4-the-check-blocks-on-every-pull-request-and-runs-locally-where-hooks-are-installed), + [D32](artifacts/decision-log.md#d32-the-guarantee-is-stated-per-surface-because-only-the-release-can-refuse)). - **The three work-items publishers and the shared work-items file.** All three read and annotate the same file. Step 2 changes only how one of them responds to annotations it does not recognize; it does not change what any of them writes. @@ -307,7 +437,9 @@ The people who experience this feature are maintainers and contributors at a ter contributor sees on a pull request is the message a maintainer sees on a release ([D12](artifacts/decision-log.md#d12-a-missing-plugin-stops-the-release-and-every-gap-is-named)). Naming the target is possible because "belongs in" is defined rather than left to the exception - ([D19](artifacts/decision-log.md#d19-a-plugin-and-the-targets-it-belongs-in-are-defined-positively)). + ([D19](artifacts/decision-log.md#d19-a-plugin-and-the-targets-it-belongs-in-are-defined-positively)). The message is + the same; the consequence is not. On a pull request it informs, and the contributor may proceed anyway. On a release + it refuses. - **Release stop.** Names every gap it found before stopping, not just the first one, so a maintainer learns the full set in one run ([D12](artifacts/decision-log.md#d12-a-missing-plugin-stops-the-release-and-every-gap-is-named)). - **Work-items format error.** Names the specific work items whose annotations were not recognized and what they appear @@ -335,29 +467,40 @@ The people who experience this feature are maintainers and contributors at a ter - **Documents already stale for reasons this work does not touch.** Several enumerations elsewhere in the repository are out of date but are not falsified by this work, and no evidence suggests anyone has been misled by them. Fixing them because the editor is already open is the symmetry reasoning this spec's own rules reject - ([D7](artifacts/decision-log.md#d7-a-document-is-in-scope-when-this-work-falsifies-it)). + ([D7](artifacts/decision-log.md#d7-a-document-is-in-scope-when-this-work-falsifies-it)). The one exception is narrow + and stated in step 6: an already-false sentence **inside a passage this work is rewriting anyway** is corrected rather + than stepped around, because leaving a known-false line in a paragraph you are editing is its own kind of dishonesty + ([D33](artifacts/decision-log.md#d33-an-already-false-statement-inside-a-rewritten-passage-is-corrected)). Proximity + to an open editor is not the test; being inside the rewrite is. - **Agreement between a plugin's own name as recorded on each channel.** Plausible by analogy to the version drift, but no instance exists and nothing depends on it today. ## Deferred (YAGNI) - **A check that every declared dependency is actually used by the declaring plugin.** This would have caught step 5's - two untrue declarations automatically, which makes it tempting. It fails the evidence test: nobody has asked for it, - no incident is attributed to it, and the two known instances are being deleted by hand. Building a general mechanism - from two instances is machinery ahead of need. **Reopening trigger:** a third decorative dependency is found, or a - decorative dependency causes a real install or breakage problem. + untrue declarations automatically, which makes it tempting — and the temptation grew during review, when a third + instance was found that the hand survey had missed. That is worth being honest about: the trigger this item originally + named ("a third decorative dependency is found") **has fired**, and the item is still deferred, because the trigger + was the wrong test. Finding a third instance by hand is evidence the survey was incomplete, not evidence that a + standing mechanism is needed. All three are being deleted in step 5, after which the count is zero and the checker + would guard against a class with no current members. Nobody has asked for it and no incident is attributed to it. + **Reopening trigger, restated:** a decorative dependency appears *after* step 5 lands — meaning the suite regrows them + and the deletion did not hold — or a decorative dependency causes a real install or breakage problem. The instances + this work removes cannot fire it. - **Designing for channel two gaining bundle support.** The exception is named and permanent today ([D6](artifacts/decision-log.md#d6-the-bundle-is-a-permanently-named-exception-on-the-second-channel)). Building a configurable exception mechanism for one known exception is speculative. **Reopening trigger:** channel two ships bundle support, or a second permanent exception appears. - **A disable switch for the check.** Deferred deliberately, not overlooked. A disable mechanism is the "land it disabled" alternative already rejected, and reversibility already exists: the check lands in one commit, so reverting - that commit is the escape hatch. **Reopening trigger:** the check produces its first false positive on a pull request - that is actually correct ([D28](artifacts/decision-log.md#d28-the-check-ships-with-no-disable-switch)). + that commit is the escape hatch. **Reopening trigger:** the rule produces its first false positive that stops a + release which is actually correct. The trigger is worded around the release rather than the pull request because the + release is the only surface where a false positive costs anything — on a pull request it can be merged past + ([D28](artifacts/decision-log.md#d28-the-check-ships-with-no-disable-switch)). - **Monitoring channel two's published state from outside the repository.** No dashboard, no version polling, no alert on - release deviation. The check on every pull request is the signal, and the drift persisted for twenty releases - precisely because nothing asked the question at all — not because nobody was watching a graph. - **Reopening trigger:** drift recurs despite the check, meaning the check is asking the wrong question. + release deviation. The release gate is the signal, and the drift persisted for eleven releases precisely because + nothing asked the question at all — not because nobody was watching a graph. + **Reopening trigger:** drift recurs despite the gate, meaning the gate is asking the wrong question. ## Open items @@ -368,30 +511,94 @@ The people who experience this feature are maintainers and contributors at a ter [T1](artifacts/feature-technical-notes.md#t1-update-availability-on-channel-two-is-decided-by-the-published-version-number). 2. **Which revision each channel's client resolves from** — the default branch or the latest release tag — is unknown from inside this repository. If it is the tag, then steps 1 and 4 reach users at the next release rather than on - merge, and both steps' outcomes should be worded accordingly. This is the same class of unknown as item 1: external - client behavior not visible from here. -3. **Whether the pull-request pipeline's lint job is a required status check** determines how much of the guarantee the - check actually carries. If it is not required, a pull request can merge red and the check is advisory on the one - surface that does not depend on a contributor's local setup. + merge. Both steps and the Outcome are now worded to hold either way, so this changes when the fix arrives rather than + whether it works. It also weakens step 1's rationale for going first, without changing the answer. This is the same + class of unknown as item 1: external client behavior not visible from here. +3. **Nothing makes the pull-request check blocking, and no step in this specification changes that.** This was carried + as an unknown and is now answered: the default branch is unprotected, no rules apply to it, and the sole ruleset is + disabled and contains no required-check rule, so enabling it as written would not help + ([T2](artifacts/feature-technical-notes.md#t2-a-pull-request-check-blocks-a-merge-only-where-a-required-status-check-demands-it)). + The specification absorbed the answer rather than the question: the guarantee is now stated per surface + ([D32](artifacts/decision-log.md#d32-the-guarantee-is-stated-per-surface-because-only-the-release-can-refuse)). What + remains open is a **decision, not a fact** — whether to make the check required, which is a repository-settings + change no step owns. Worth knowing before deciding: the existing disabled ruleset also demands an approving review, + which on a solo-maintained repository would block the maintainer from merging their own work, and is the likeliest + reason it is switched off. Requiring the check alone is the smaller move. **Owner: the maintainer.** Does not block + any step. 4. **Version compatibility between plugins is an open question this specification does not close.** Plugins are installed and updated one at a time, so someone can run a months-old coding plugin against a core plugin updated today, and nothing would notice. Earlier analysis argued this could not happen because everything ships from a single snapshot; that argument holds only for a fresh install of everything at once, which is not how the suite is used over time. The right fix is not obvious. Flagged as a decision the team still owes itself, not as work this specification - schedules. + schedules. It is kept here rather than removed for having no dependent step, because unlike the item removed earlier + in planning it has nowhere else to live: there is no follow-up specification to carry it. An open question with no + home stays with the document that found it. ## Summary -- **Outcome.** Han publishes completely and honestly to both channels, and a check keeps it that way. +- **Outcome.** Han publishes completely and honestly to both channels, and no release can quietly stop it being so. - **Actors.** Han maintainers, contributors, and channel-two installers. -- **Scope.** Seven steps. The check is last; the release repair precedes the version correction; the declaration - deletion and document correction are one unit; the work-items fix is independent. -- **Decisions.** 30 full and trivial combined. See [decision-log.md](artifacts/decision-log.md). -- **Technical notes.** 1. See [feature-technical-notes.md](artifacts/feature-technical-notes.md). -- **Sub-agents.** 4: junior-developer, devops-engineer, edge-case-explorer, information-architect. See - [team-findings.md](artifacts/team-findings.md). -- **Key adjustments from review.** The execution order was changed to close a window that would have undone step 4; the - gate was given a placement; the check and release were given one bearer instead of two; the document survey was +- **Scope.** Seven steps. The check is last; the release repair and the version correction are one unit; the + declaration deletion and document correction are one unit; the work-items fix is independent. +- **Decisions.** See [decision-log.md](artifacts/decision-log.md). +- **Technical notes.** See [feature-technical-notes.md](artifacts/feature-technical-notes.md). +- **Sub-agents.** Planning: junior-developer, devops-engineer, edge-case-explorer, information-architect — see + [team-findings.md](artifacts/team-findings.md). Review: junior-developer, adversarial-validator, + evidence-based-investigator, devops-engineer, edge-case-explorer — see + [review-findings.md](artifacts/review-findings.md). +- **Key adjustments from planning.** The execution order was changed to close a window that would have undone step 4; + the gate was given a placement; the check and release were given one bearer instead of two; the document survey was restated as a rule and grew by five locations; and a factual error about the number of version records was corrected. +- **Key adjustments from review.** The headline enforcement claim was found to have no bearer and was restated per + surface — only the release refuses, a pull request merely reports. Steps 3 and 4 became one unit, closing a release + freeze the reorder had opened. The release gained the ability to create a missing record rather than only report it. + A third untrue dependency declaration was found. The bundle's version exemption was split, and the release count was + corrected from "roughly twenty" to eleven. - **Deferred under YAGNI.** 4. -- **Open items.** 4, of which two are unverified external behaviors and one is a real decision the team owes itself. +- **Open items.** 4, of which two are unverified external behaviors, one is a settled fact carrying an unmade decision, + and one is a real question the team owes itself. + +## Review History + +- **Review mode:** team. +- **Spec-aware mode:** engaged. +- **Rounds completed:** 1 of a 3-round cap — see + [artifacts/review-iteration-history.md](artifacts/review-iteration-history.md). **Not stable; another round is + recommended.** +- **Team composition:** + - `junior-developer` — required; reframes the spec in plain terms and surfaces hidden assumptions and standards + conflicts. + - `adversarial-validator` — required; attacks the evidence, the resolutions the planning pass produced, and the + ordering argument. + - `evidence-based-investigator` — included because the spec's foundation is repository-state claims, even though its + channel-neutral prose contains no file paths. + - `devops-engineer` — the release gate, the enforcement surface, and the rollout ordering are the spec's spine. + - `edge-case-explorer` — the failure-mode table and the stop-before-create gate carry the spec's silent-failure + commitments. +- **Findings raised:** 16 — see [artifacts/review-findings.md](artifacts/review-findings.md). 14 major, 2 minor. By + resolution source: 4 by user input on surfaced trade-offs, 12 by evidence. +- **YAGNI candidates:** 0 new items raised as `Category: YAGNI candidate`. The planning pass's YAGNI work held up under + challenge — nothing smuggled back into scope, and all four deferrals carry live triggers. One deferral's trigger was + found to have already fired and was restated rather than silently left standing ([F31](artifacts/review-findings.md#f31-han-linear-is-a-third-untrue-dependency-declaration-and-the-specs-own-deferral-trigger-has-already-fired)), + and one kept behavior had its justification replaced after the original evidence was withdrawn + ([F43](artifacts/review-findings.md#minor-edits)). +- **Assumptions challenged across all passes:** that a red pull-request check blocks a merge (false — nothing here makes + it blocking); that the rule only starts refusing at step 7 (false — it refuses from step 3, because the release runs + it); that the repaired release could fix what it found (false — it could only report); that two plugins carried untrue + dependency declarations (false — three do); that the bundle has nothing to disagree with (false — it publishes a + version in two records); and that the drift spanned roughly twenty releases (false — eleven). +- **Consolidations made:** steps 3 and 4 became one unit, joining steps 5 and 6, so the binding constraints now name two + units rather than one unit and one ordering. The check and the release keep one bearer; what changed is that the spec + now states what that bearer can enforce on each surface. +- **Ambiguities resolved, and how:** what version a created record carries (D31, by extension from what a release + already writes); which documents state the rule versus keep their enumeration (D26, scoped to the sentence shape it + was reasoned from); whether an already-false neighbor is corrected (D33, stated once as a rule that generalizes what + D9 and D27 were already doing); and what happens to a record that cannot be read at all (D35, previously undefined). +- **Technical notes added/edited:** 1 added — see + [artifacts/feature-technical-notes.md](artifacts/feature-technical-notes.md). +- **Open items remaining:** 4. None blocks implementation. Items 1 and 2 are external client behaviors that shape how + outcomes are worded rather than whether steps work ([F35](artifacts/review-findings.md#f35-step-1s-install-succeeds-promise-is-unhedged-against-open-item-2-while-the-parallel-claim-is-hedged)). + Item 3 is now a settled fact carrying an unmade decision, not an unknown + ([F28](artifacts/review-findings.md#f28-the-check-cannot-block-a-merge-so-the-outcomes-enforcement-guarantee-has-no-bearer)). + Item 4 is a question the team owes itself, kept here because it has nowhere else to live + ([F42](artifacts/review-findings.md#minor-edits)). From ca3fa6b084f6fc62f149b18f578165cf1e4732b3 Mon Sep 17 00:00:00 2001 From: River Lynn Bailey <river.bailey@testdouble.com> Date: Thu, 16 Jul 2026 14:01:02 -0600 Subject: [PATCH 03/22] docs(plans): apply R2 review findings to han-publishing-cleanup spec Round 2 of the iterative review found that R1's two headline resolutions contradicted each other: F30 gave the release the ability to repair, which means the gate never sees the version disagreements F29's steps-3+4 unit was created to survive. The unit is reverted to the ordering D18 always recorded. Also scopes creation to the two channel-two targets where the evidence lives, stops it at content that must be authored, commits every target the release writes, and extends the bundle's exception to the creation verb. Adds D36-D40; corrects D6, D18, D24, D31, D33, D34, D35, D8, D19. --- .../artifacts/decision-log.md | 376 +++++++++++++++++- .../feature-specification.md | 346 ++++++++++++---- 2 files changed, 622 insertions(+), 100 deletions(-) diff --git a/docs/plans/han-publishing-cleanup/artifacts/decision-log.md b/docs/plans/han-publishing-cleanup/artifacts/decision-log.md index 5c9be541..87fec859 100644 --- a/docs/plans/han-publishing-cleanup/artifacts/decision-log.md +++ b/docs/plans/han-publishing-cleanup/artifacts/decision-log.md @@ -194,9 +194,17 @@ source artifact's "Before" diagram: "DISK -.-> |never consulted directly| REL". ### D6: The bundle is a permanently named exception on the second channel -**Outcome.** The bundle is a named, permanent exception **to the comparison between channels**. Its absence from channel -two is never flagged, and it is never asked to agree with a channel-two record it does not have. It is **not** exempt -from the agreement between channel one's own two records, which it does have. +**Outcome.** The bundle is a named, permanent exception **to everything the rule does on channel two**. Its absence +there is never flagged, it is never asked to agree with a record it does not have, and **a release never creates one for +it**. It is **not** exempt from the agreement between channel one's own two records, which it does have. + +> **Extended in R2.** This exception was stated in two verbs — flag, agree — both of which are about looking. D31 gave +> the release a third verb it can do on channel two: create. The exception never acquired it, and the specification's +> "the exception stops there" actively instructed a narrow reading. Left alone, a repairing release would publish the +> bundle to the one channel that cannot install it, and the same exception that tells the rule not to look there would +> have kept the mistake silent. The exception is now stated against what the rule does on that channel rather than +> against an enumerated verb list, so the next verb added inherits it +> ([F50](review-findings.md#f50-the-bundles-exception-is-stated-in-verbs-that-predate-the-releases-ability-to-create)). **Rationale.** The limitation is real, external, and documented with a specific upstream tracking issue. An exception the rule does not know about would fire on every run forever, and a check that always reports one known failure trains people @@ -323,7 +331,23 @@ meta-plugin does not bundle it"). codebase — the dependency is verified **by u `Skill` tool, and `:15` of the sibling wrapper skills grants `Skill` and `Agent` in `allowed-tools`, so the plugin both can and does call the core plugin. This test is deliberate: the manifest description also asserts the dependency, but a description asserting a dependency is exactly the evidence that was false for `han-linear`, so it cannot be what -qualifies the replacement. codebase — `han-coding/.claude-plugin/plugin.json` declares +qualifies the replacement. + +codebase — the other three declared edges were verified in R2 by the same test, because this decision applied it to one +edge and called the manifest true on the strength of it. `han-atlassian/skills/plan-a-feature-to-confluence/SKILL.md:63` +invokes `han-planning:plan-a-feature`; `investigate-to-confluence/SKILL.md:57` invokes `han-coding:investigate`; +`code-overview-to-confluence/SKILL.md:58` invokes `han-coding:code-overview`. The `han-communication` edge is the one +that needed a stated test rather than a grep: `han-atlassian` never names it, and by the literal-invocation test it +would look exactly like `han-linear`'s untrue declaration. It survives on a different and documented basis — +`README.md:84-85` records that the channel-two client resolves no dependencies, so `han-atlassian` declares what its +**wrapped** skills need in order for an install to bring it in, and those wrapped skills do invoke it +(`han-core/skills/project-documentation/SKILL.md:65,162`; `han-coding/skills/investigate/SKILL.md:37,131`). +`docs/concepts.md:225-227` documents the same transitive-necessity pattern for the `han-planning` and `han-coding` +edges. The distinction that matters, and that this decision left unstated: a declaration whose *wrapped* skills exercise +it is real; a declaration whose plugin is not granted the means to call anything is not +([F62](review-findings.md#minor-edits)). + +codebase — `han-coding/.claude-plugin/plugin.json` declares `["han-communication", "han-core"]`, matching the ":108" "second layer on top of core" role. codebase — the tutorial never mentions Codex; it teaches `.claude-plugin/plugin.json` and `/plugin install …@han` throughout. provided — the user selected the repoint-rather-than-rewrite option. @@ -578,6 +602,15 @@ GitHub-annotated), so a Jira- or Linear-annotated heading matches neither. codeb plugin, close the work-items hole, repair the release, correct the versions, delete the declarations, correct the documents, turn on the check. The binding constraints are named; the rest are free. +> **Vindicated in R2, without an edit.** R1 changed the specification to make the release repair and the version +> correction "one unit", citing this decision for it. This decision never said that — it said "precedes", and it was +> right. The unit claim rested on a release freeze that a repairing release does not produce, and the specification has +> been returned to what this entry always said. Recorded here because the R1 finding's resolution named four +> specification sections and no decision, which is how a decision and the document citing it came to disagree for a +> round ([D38](#d38-the-repair-and-the-correction-are-ordered-not-united), +> [F44](review-findings.md#f44-the-release-repairs-disagreements-so-the-freeze-the-3-4-unit-closes-never-happens), +> [F53](review-findings.md#minor-edits)). + **Rationale.** Two problems with the original total order. First, it was not a total order. The source plan's ordering argument is entirely about the check (D1), which forces one @@ -631,7 +664,7 @@ only to channel two would be invisible — today's bug mirrored onto the other c **Evidence.** codebase — `han/.claude-plugin/plugin.json` exists with no `skills/` directory, and `CLAUDE.md` describes the bundle as having "no components of its own", so zero-skill is a permanent legitimate state. codebase — no stray -`plugin.json` exists outside the ten real plugin directories today (`han-plugin-builder`'s templates are named +`plugin.json` exists outside a real plugin directory today (`han-plugin-builder`'s templates are named `plugin-example.json`, avoiding collision), so no false positive exists to design around. codebase — every current listing entry resolves to a real directory. @@ -797,6 +830,24 @@ other step touches. **Outcome.** One gate. It runs after the release has written all four targets and before it commits, tags, pushes, or publishes. +> **Corrected in R2, on two points.** +> +> **The gate cannot precede operator authorization, and the specification no longer claims it does.** R1 added a third +> anchor — the gate runs "before it asks anyone to approve publishing" — reasoning that a gate refusing after approval +> teaches distrust. The prompt that anchor names is opt-in and off by default, so on the ordinary path nobody is asked to +> approve publishing at all. Worse, the approval that *does* always happen is the release's confirmation of its version +> plan, and it runs **before** the target writes this decision requires the gate to follow. So a refusal after an +> approval is not a risk to be placed around; it is structural. The specification now says so and states what the gate +> owes instead: nothing published, and every gap named at once +> ([F51](review-findings.md#f51-the-gates-approval-anchor-names-a-prompt-that-is-off-by-default-and-misses-the-one-that-is-not)). +> +> **"It settles what stops a release cut from a branch where a step has not landed" no longer holds.** It was true of a +> gate that refused over version disagreement. A repairing release closes those, so a branch missing step 4 is repaired +> rather than stopped, a branch missing step 3 has no gate at all, and steps 5 through 7 are not things the gate inspects. +> The gate's job is the gaps a release cannot close, not the steps of this plan +> ([D38](#d38-the-repair-and-the-correction-are-ordered-not-united), +> [F52](review-findings.md#f52-the-branch-cut-flows-backstop-claim-does-not-survive-a-release-that-repairs)). + **Rationale.** The original spec said the release "stops before publishing anything". In the release skill's own vocabulary "publish" names its final step, so that sentence permitted stopping *after* the tag was pushed — the irreversible act D12's rationale says it exists to prevent. @@ -832,9 +883,9 @@ stop… surface the fact, do not block"), and `.github/workflows/ci.yml` trigger - _Two gates, one for membership and one for versions._ Rejected: two placements, two things to keep aligned, no benefit over one gate placed where both questions are answerable. -**Driven by findings:** F2, F11, F23 +**Driven by findings:** F2, F11, F23, F51, F52 (corrections) **Linked technical notes:** — -**Dependent decisions:** — +**Dependent decisions:** D31, D36, D37, D38 **Referenced in spec:** Primary flow (Step 3), Alternate flows and states, Coordinations ### D25: Manifest descriptions are documents; the declarations are the record @@ -1079,7 +1130,21 @@ on the surrounding text" as a Step 3 validation finding, so malformed headings a **Outcome.** A release brings a target up to date by creating a record that does not exist, not only by updating one that has fallen behind. A record created this way is written at the version the release is publishing for that plugin — -for a plugin the release did not bump, that is the version it already has. +for a plugin the release did not bump, that is the version it already has, read from that plugin's own channel-one +record and never from a listing. + +> **Corrected in R2.** Three claims below did not survive review and are corrected here rather than rewritten in place, +> so the reasoning that produced them stays legible. **(1)** Creation is scoped to the two channel-two targets, not all +> four — see [D36](#d36-a-release-creates-what-it-can-derive-and-stops-at-what-must-be-authored). **(2)** "Creating an +> entry [in channel two's listing] means adding the plugin's membership and nothing more" is false: that entry also +> carries the policy deciding whether the plugin is installable, and a per-plugin record carries the plugin's authored +> storefront presence. Also D36. **(3)** "There is no plugin for which the phrase is undefined" is false for the +> channel-two-only plugin [D19](#d19-a-plugin-and-the-targets-it-belongs-in-are-defined-positively) deliberately +> admits; the phrase is defined for every plugin carrying a channel-one record, which is every plugin today. A plugin +> whose publishing version cannot be determined is a gap creation cannot close. **(4)** The scope of creation is bounded +> by "belongs in" ([D19](#d19-a-plugin-and-the-targets-it-belongs-in-are-defined-positively)) and by the bundle's +> exception ([D6](#d6-the-bundle-is-a-permanently-named-exception-on-the-second-channel)), which this decision never +> said. **Rationale.** D5 changed what the release can see. It did not change what the release can write, and the difference is the whole value of the repair. The release's version-writing step acts only on plugins whose target version differs from @@ -1125,9 +1190,9 @@ during review. across two efforts and leaves the seven steps shipping a release that still cannot repair the defect they exist to repair. -**Driven by findings:** F30 +**Driven by findings:** F30, F46, F47, F48 (corrections) **Linked technical notes:** — -**Dependent decisions:** D5, D22, D24, D29, D35 +**Dependent decisions:** D5, D6, D19, D22, D24, D29, D35, D36, D37, D38, D39 **Referenced in spec:** Outcome, Primary flow (Step 3), Alternate flows and states, Edge cases, Coordinations ### D32: The guarantee is stated per surface, because only the release can refuse @@ -1174,7 +1239,19 @@ enforcement when the trade was surfaced during review. ### D33: An already-false statement inside a rewritten passage is corrected **Outcome.** A false statement inside a passage this work is already rewriting is corrected. A false statement -elsewhere is not. +elsewhere is not. **A passage is the paragraph.** + +> **Bounded in R2.** This decision stated a sentence-level rule and then applied a proximity-level one. Its worked +> example — the orientation document's description of the bundle's own dependencies — sits in its own paragraph between +> two paragraphs this work rewrites, so "the sentence is being rewritten regardless" was not true of it. The rule as +> applied was "sits nearby", which is the reasoning D7 and the Out-of-scope section explicitly reject. +> +> The boundary is now stated: a passage is the paragraph. That is the unit a person actually rewrites, it is narrower +> than the document-wide audit this specification refuses, and it is wide enough to be usable. Under it, the worked +> example does **not** qualify — and it is corrected anyway by a route it does qualify under, as one of the dependency +> enumerations D26's remedy is already rewriting. The distinction is the point: one route corrects it for a reason, the +> other corrects it because the editor was open +> ([F55](review-findings.md#f55-d33-states-a-sentence-rule-and-applies-a-proximity-rule)). **Rationale.** The spec was resolving this case three incompatible ways. D7 and the Out-of-scope section keep already-stale documents out, because "the editor is already open" is convenience rather than evidence. D9 pulls an @@ -1211,7 +1288,29 @@ is omitted. The surrounding lines are in step 6's scope. ### D34: A gate stop costs a separate commit, because the release refuses a dirty tree **Outcome.** The specification states two recoveries rather than one. A partial write is recovered by discarding local -changes and re-running. A gate stop is recovered by correcting the gap and committing it, and only then re-running. +changes and re-running. A gate stop is recovered in two acts, in order: **the release's own local work is discarded +first — everything it wrote and everything it created — and then the gap is corrected and committed on its own.** Only +then does the release re-run, planning from scratch. The correction must reach the branch releases are cut from, not +only the branch in front of you. + +> **Completed in R2, on three points.** +> +> **The release's own writes were never accounted for.** This decision reasoned that the gap "lives in the repository +> rather than in the release's uncommitted work" — true of the gap, false of the tree. The gate runs after the release +> has written all four targets, so a gate stop always leaves the release's work in the tree, and the two stated +> recoveries overlapped rather than composing. +> +> **The obvious recovery disarms the release's only mandatory confirmation.** Committing the gap fix together with the +> release's half-applied version writes makes those versions look like bumps made deliberately during development. The +> release's next run then takes the ahead path for those plugins, needs no confirmation, and publishes versions nobody +> approved — a step of the release quietly deciding something a person was supposed to decide. Discarding first is what +> prevents it, and the order is therefore load-bearing rather than tidy. +> +> **Created files survive a careless discard.** Creation writes files that are untracked, so a discard aimed at modified +> files leaves them behind and the tree stays dirty. The recovery names creation explicitly for that reason. +> +> None of this reopens D28 ([F54](review-findings.md#f54-the-gate-stop-recovery-does-not-account-for-the-releases-own-writes-and-the-naive-move-skips-the-mandatory-confirmation), +> [F57](review-findings.md#minor-edits)). **Rationale.** The spec gave both cases the same recovery, and for a gate stop it is a no-op that loops. The release hard-stops when the working tree is dirty, so the sequence is: gate stops, discard local changes, the gap is still there @@ -1281,11 +1380,260 @@ hand-editing slip during steps 1 or 4, or an interrupted write from the release' - _Leave it to implementation._ Rejected: the silent-skip behavior is the default of the code being extended, so leaving it unstated is choosing it. -**Driven by findings:** F40, F41 +**Driven by findings:** F40, F41, F59 (extension to the shared listing) **Linked technical notes:** — -**Dependent decisions:** D5, D19, D20, D24, D31 +**Dependent decisions:** D5, D19, D20, D24, D31, D36 **Referenced in spec:** Edge cases and failure modes +> **Extended in R2.** This decision was written and evidenced in terms of a per-plugin record. A storefront listing is +> one shared file covering every plugin, so the same parse failure there would present as "every plugin is missing from +> this target" and route the whole channel into D31's create-path — regenerating the file rather than surfacing it. An +> unreadable listing is surfaced and blocking for that channel, never read as mass absence +> ([F59](review-findings.md#f59-a-corrupt-storefront-listing-would-route-a-whole-channel-into-the-create-path)). +> +> Its non-circularity argument is also softened. It claimed a plugin's identity survives an unreadable manifest because +> "D19 defines a plugin by the manifest's presence, not its contents" — which asserts a filesystem-based identity, while +> the release's existing convention matches records by the name **inside** them. The behavioral commitment does not +> depend on the choice: the failure can be named by the directory it was found in. The argument, not the outcome, was +> overstated ([F60](review-findings.md#minor-edits)). + +### D36: A release creates what it can derive and stops at what must be authored + +**Outcome.** Creation reaches the two channel-two targets and stops there. A plugin missing from a channel-one target, +a plugin whose publishing version cannot be determined, and a plugin with no authored storefront presence are gaps the +release names and refuses rather than gaps it closes. + +**Rationale.** D31 committed the release to creation on all four targets and described a created record as carrying a +version and, on channel two's listing, "membership and nothing more". Both are wrong on disk, and the second is the +interesting one. + +A target carries more than a version. Channel two's per-plugin record carries the plugin's storefront presence — its +display name, a short description, a long description, a category, and a set of example prompts — none of which derives +from anything the repository holds. Channel two's listing entry carries the policy that decides whether the plugin is +installable at all, which is the Outcome's headline promise sitting in a field D31 said was empty of everything but +membership. Channel one's listing entry carries a description, which D25 already classifies as a document. So "create +the record" is, for three of the four targets, partly an instruction to write prose. + +A release that composed that prose would be publishing writing nobody authored to a page people read, and it would be +doing it unattended. This is not a capability the release lacks in a way worth fixing; it is a boundary worth keeping. +The version is derivable and the presence is not, and the honest line runs exactly there. + +Scoping creation to channel two follows from where the evidence is. The Linear plugin is missing from channel two, live, +today — that is the incident. Nothing has ever been missing from channel one's listing, and nothing can be missing from +channel one's per-plugin record, because carrying one is part of what makes a directory a plugin (D19). Committing the +release to create the channel-one listing entry would be building the most expensive half of the capability — the half +that has to author a description — for the case with no members. That is the symmetry-and-completeness reasoning this +specification rejects elsewhere, and applying it here would have been the specification failing its own test. + +What is left is small and true: a release creates the two channel-two records, at a version it can derive, for a plugin +whose presence a person already authored. Everything else is a gap it names. + +**Evidence.** codebase — a channel-two per-plugin record carries `keywords` and an `interface` block (`displayName`, +`shortDescription`, `longDescription`, `developerName`, `category`, `capabilities`, `websiteURL`, `defaultPrompt`), +none of which has a channel-one source; channel one's per-plugin record carries only `name`, `description`, `version`, +`dependencies`. codebase — every channel-two listing entry carries +`"policy": {"installation": "AVAILABLE", "authentication": "ON_INSTALL"}` plus a category. codebase — every channel-one +listing entry carries an authored `description`. codebase — all eleven plugin directories carry a channel-one record, +and all twenty listing entries across both channels resolve to a real directory, so neither channel-one creation case +has a live instance. provided — the user chose to scope creation to channel two and to make an unauthored presence a +gate stop, over keeping creation universal. + +**Rejected alternatives.** + +- _The release authors the presentational content._ Rejected by the user. Defensible — the release already does judgment + work, including authoring a changelog through a sub-agent — but it puts unattended storefront copy in front of users, + and D25 already calls that class of content a document. +- _The release creates a stub and the plugin ships with placeholder presentation._ Rejected by the user: it publishes a + visibly incomplete listing to the channel this work exists to repair. +- _Keep creation on all four targets._ Rejected: three of the four have no evidence of ever having been missing, and the + one requiring authored prose is the least likely to be needed and the most expensive to serve. +- _Invent a version-inference rule for a plugin with no channel-one record._ Rejected: zero members, and it is machinery + for a shape nothing schedules. The gate already has the right verb for it. + +**Driven by findings:** F46, F47, F48 +**Linked technical notes:** — +**Dependent decisions:** D19, D25, D29, D31, D35 +**Referenced in spec:** Channels and targets, Outcome, Primary flow (Step 1, Step 3), Alternate flows and states, Edge +cases and failure modes, Coordinations + +### D37: The release commits every target it writes + +**Outcome.** Every target the release writes or creates travels into the commit it tags. The state the gate approved is +the state that ships. + +**Rationale.** The specification committed the release to writing four targets and to a gate that judges the written +state, and never said what the release does with the writes afterward. The release's commit stages an enumerated list of +file classes, all of them on channel one. Left alone, the first release after the repair would write four targets, pass +the gate on the four-target state, and commit two of them. + +The consequences are all silent and all bad. The tag names a tree in which channel two is still frozen, so if that +channel's client resolves from the tag, the entire repair reaches nobody while the gate reports green. The repaired +records stay uncommitted, so the next release hard-stops on a dirty tree, one release removed from its cause. And a +release whose only work was creating a missing plugin's records — the Linear-plugin shape, the motivating case — stages +nothing at all and skips its own commit. + +The gate cannot catch any of it, by construction: it runs before the commit, so it inspects a tree the release then +declines to publish. This is the original defect — the release cannot see the targets it does not touch — relocated from +the write step to the commit step, and it would have shipped inside the fix. + +**Evidence.** codebase — `.claude/skills/han-release/SKILL.md:326-328` stages `CHANGELOG.md`, +`.claude-plugin/marketplace.json`, and every `{source}/.claude-plugin/plugin.json` that the version step changed; both +channel-two targets are absent, and the same file contains no reference to either. codebase — `:328` skips the commit +entirely "if nothing is staged", a case the same line calls "unlikely" and which creation makes the motivating one. +codebase — `:72-74` hard-stops on a dirty tree. codebase — `:333-335` tags the release commit and pushes it. + +**Rejected alternatives.** + +- _Leave it to the implementation plan._ Rejected: the specification already makes a behavioral promise about what a + release publishes, and this is the step where that promise is kept or lost. Which files get staged is implementation; + that the released state is the gated state is not. +- _Have the gate re-run against the committed state._ Rejected for now: it is a second mechanism for a problem one + sentence closes. Worth revisiting only if the committed and gated states are ever observed to differ. + +**Driven by findings:** F45 +**Linked technical notes:** — +**Dependent decisions:** D24, D31, D34 +**Referenced in spec:** Outcome, Primary flow (Step 3), Coordinations + +### D38: The repair and the correction are ordered, not united + +**Outcome.** Steps 3 and 4 are an ordering, not a unit. The repair precedes the correction so the correction is durable. +Step 4 is worth doing by hand because it makes the numbers right on merge rather than at the next release — not because +anything breaks without it. + +**Rationale.** This reverses a claim R1 added and restores what D18 has said all along, which is worth being explicit +about because the decision log was right and the specification's edit was the error. + +R1 merged steps 3 and 4 on this argument: the release runs the rule, so the gate is live from step 3; every plugin but +one has disagreeing version records until step 4 corrects them; therefore a release cut between step 3 and step 4 +hard-stops, freezing releases until the versions are fixed. The premise held for the gate as it existed when that +argument was made — a gate that only detected. It stopped holding in the same round, when the release gained the ability +to repair. + +A repaired release corrects a stale version rather than refusing over it. The gate runs after the writes, so by the time +it looks, the eight disagreements are gone — the release closed them. There is no window and there never was one. The +two resolutions were decided in the same round and never checked against each other. + +What survives is the original ordering and the original reason. Repair first, so that the first release after the +correction keeps it correct rather than re-freezing it; had the correction come first, any release cut before the repair +would have undone it. That is D18's "precedes", unchanged. + +Step 4 keeps its place on a smaller claim. Once step 3 lands, the first release corrects these numbers whether or not +step 4 ever runs. What step 4 buys is that they are correct on merge instead of whenever someone next cuts a release — +which matters more if channel two's client resolves from the default branch and less if it resolves from the tag +(Open item 2). That is a real reason and a modest one, and it is the true one. + +One consequence is worth stating rather than discovering: the two surfaces now answer differently. A pull request +reports drift a release would have repaired, because a pull request has nothing that repairs. A release reports only +what is left after it has repaired everything it can. Same rule, same bearer, different moment — which is D14 working, +not D14 breaking. + +**Evidence.** codebase — the release's version step acts only "For every plugin whose `target` differs from its +`current`" and skips the rest (`.claude/skills/han-release/SKILL.md:230,237-238`), which is precisely the skip step 3 +removes when it commits the release to bringing every target up to date. codebase — eight plugins carry a disagreeing +channel-two version today and one agrees; the Linear plugin has no channel-two record and, per D22, arrives agreeing. +provided — the user confirmed that a release overwrites a stale version record for a plugin it did not bump, and chose +to restate the unit rather than narrow the repair. + +**Rejected alternatives.** + +- _Keep the unit; make the release create but never overwrite a disagreeing record._ Rejected by the user. It would + preserve F29's reasoning, but it requires explaining why writing a version onto a missing record is safe and writing + the same version onto a stale one is not — two identical acts, treated differently to protect an argument. +- _Keep the unit with no restated reason._ Rejected: the stated reason is false, and a binding constraint held in place + by a false reason is how the next reviewer gets misled. +- _Drop step 4 entirely and let the first release do it._ Rejected: it makes the correction wait on a release, and + ties the user-visible fix to a cadence nobody controls. +- _Surface it as an open item for the implementation plan._ Rejected: it decides whether one of the seven steps exists + and whether a binding constraint is real. + +**Driven by findings:** F44 +**Linked technical notes:** — +**Dependent decisions:** D1, D14, D18, D24, D31 +**Referenced in spec:** Primary flow (binding constraints, Step 1, Step 3, Step 4, Step 7), Alternate flows and states, +Edge cases and failure modes, Coordinations, User interactions + +### D39: A release reports what it created + +**Outcome.** A release that creates or corrects a record says so — which plugin, which targets, at what version — +alongside the version plan it already reports. + +**Rationale.** This specification is careful that refusals are loud. The gate names every gap rather than the first; a +check failure names the plugin and every target; malformed is a category rather than a residue. Creation is the +capability the Outcome now advertises, and nothing told anyone it happened. + +The release's own reporting has no vocabulary for it either: its version plan says bumped, unchanged, or new, and a +release whose plan needs no confirmation prints nothing at all. So a release could create a plugin's channel-two +membership and version record, publish it, and never mention it. + +A process that writes to what Han publishes without saying so is a smaller instance of the defect this work exists to +end. The whole diagnosis is that something quietly stopped happening and nothing asked; a repair that quietly starts +happening is the same shape wearing better clothes. The remedy is one line in a report that already exists. + +This is also the strictly simpler answer to a larger worry. Creation makes a directory publicly installable with no +sign-off, which is a bigger act than the version bump that does get confirmed. A confirmation gate would close that; +reporting what happened is cheaper, uses reporting the release already does, and is enough while no instance of the +worry exists (see Deferred (YAGNI)). + +**Evidence.** codebase — the release prints a version plan with one line per plugin, in the vocabulary bumped / +unchanged at version / new at version (`.claude/skills/han-release/SKILL.md:306-315`, `:220-226`), with no term for a +created record; `:219-221` prompts for nothing when no plugin needs confirmation. Behavioral — D12 already establishes +that naming the full set at once is what the release owes a maintainer; this applies the same principle in the +affirmative direction. + +**Rejected alternatives.** + +- _Confirm before creating public membership._ Deferred rather than rejected — see Deferred (YAGNI). Reporting satisfies + the same concern at a fraction of the cost, and the case it guards against has never occurred. +- _Say nothing; the commit shows it._ Rejected: a diff is not a report, and the person who needs to know is the one + deciding whether to publish. + +**Driven by findings:** F49 +**Linked technical notes:** — +**Dependent decisions:** D12, D31, D36 +**Referenced in spec:** Primary flow (Step 3), Alternate flows and states, Edge cases and failure modes, User +interactions + +### D40: A half-finished removal is not a state the release guesses at + +**Outcome.** A plugin the repository still carries is a plugin the rule expects in every target it belongs in. A +directory that remains is a plugin, and its absence from a target is a gap the release closes — so removing a plugin +means removing the directory. + +**Rationale.** Removal is not hypothetical: the release process already has a rule for it, versioning a removed child as +a major change. A removal that lands whole is invisible to this rule and needs nothing. A removal that lands +half-finished is the same four-file mistake this work exists to prevent, pointed the other way. + +The tempting answer is to have the release notice that a plugin used to be published here and is not now, and treat that +as an apparent removal a person should confirm. It is the wrong answer. It asks the rule to infer intent from absence, +which is exactly the inference that cannot be made — "never published here" and "published here until yesterday" are the +same state on disk, and the whole reason this work exists is that the first one went unnoticed for eleven releases. +Building a mechanism to distinguish them would mean consulting history to guess at a person's intention, on a case with +no live instance. + +The cheaper and truer answer is to refuse the ambiguity. The directory is the fact. If it is there, the plugin is +published everywhere it belongs, and a release will restore what was deleted. If it should not be published, the +directory goes. That gives the maintainer one thing to get right instead of four, which is the same simplification the +rest of this work is built on. + +**Evidence.** codebase — the release process versions "a child was removed from the suite" as a major bump, so removal +is an anticipated event rather than a hypothetical. Behavioral — D19 already defines a plugin by its directory and +manifest rather than by its listing membership, so this is that definition applied to the removal direction rather than +a new rule. + +**Rejected alternatives.** + +- _Treat a plugin's disappearance from a target as an apparent removal and stop for a person._ Rejected: it requires + inferring intent from absence, has no live instance, and would add a stop to the ordinary path that D31 exists to keep + moving. +- _Leave removal undefined._ Rejected: the release would silently recreate the records a half-finished removal deleted, + which is a silent write to what Han publishes. + +**Driven by findings:** F58 +**Linked technical notes:** — +**Dependent decisions:** D19, D29, D31 +**Referenced in spec:** Alternate flows and states, Edge cases and failure modes + ## Trivial decisions - **D15: Output location** — `docs/plans/han-publishing-cleanup/`, alongside the existing plan folders. Named for the diff --git a/docs/plans/han-publishing-cleanup/feature-specification.md b/docs/plans/han-publishing-cleanup/feature-specification.md index f7001ba0..4d4678ce 100644 --- a/docs/plans/han-publishing-cleanup/feature-specification.md +++ b/docs/plans/han-publishing-cleanup/feature-specification.md @@ -27,6 +27,13 @@ document is readable without it. - **A target** — any of the four files a release must keep current: two storefront listings and two per-plugin version records. Channel one's listing is both a listing and a version record, which is why the four targets do not partition evenly into "two and two". +- **What a target carries besides a version.** Every target carries more than the version this work is about. A listing + entry carries the plugin's presentation and, on channel two, the policy that decides whether it is installable at all. + A per-plugin record carries the plugin's storefront presence — the names, descriptions, and example prompts a person + wrote. None of it derives from a version number, and this work changes none of it. It is named here because it is the + difference between a version a release can compute and a presence only a person can author, which is where the + release's repair stops + ([D36](artifacts/decision-log.md#d36-a-release-creates-what-it-can-derive-and-stops-at-what-must-be-authored)). - **A plugin** — a directory the suite ships as an installable unit ([D19](artifacts/decision-log.md#d19-a-plugin-and-the-targets-it-belongs-in-are-defined-positively)). - **The bundle** — the meta-plugin that installs the suite in one command. It exists on channel one only. It is @@ -54,12 +61,17 @@ Concretely, when this work is done: if I change this?" - Every document that describes Han's plugin topology describes the topology that exists. - **Every document that describes Han's release procedure describes the procedure that exists**, so a contributor - following the contributor guide is not blocked by the check for doing what the guide told them + following the contributor guide is not flagged by the check for doing what the guide told them ([D21](artifacts/decision-log.md#d21-the-release-procedure-documents-are-owned-by-the-step-that-falsifies-them)). -- The release process starts from what is really in the repository, brings every target up to date — creating the - records a plugin is missing, not only reporting them — and stops rather than shipping around a gap it cannot close +- The release process starts from what is really in the repository, brings every target up to date — correcting a + version that has fallen behind and creating a channel-two record a plugin is missing, not only reporting them — and + stops rather than shipping around a gap it cannot close ([D5](artifacts/decision-log.md#d5-the-check-derives-the-plugin-list-from-the-repository), - [D31](artifacts/decision-log.md#d31-the-release-creates-a-missing-target-at-the-version-it-is-publishing)). + [D31](artifacts/decision-log.md#d31-the-release-creates-a-missing-target-at-the-version-it-is-publishing), + [D36](artifacts/decision-log.md#d36-a-release-creates-what-it-can-derive-and-stops-at-what-must-be-authored)). +- **What the release approves is what the release publishes.** Every target the release writes travels into the commit + it tags, so the state a gate passed is the state that ships + ([D37](artifacts/decision-log.md#d37-the-release-commits-every-target-it-writes)). - The rule is enforced where it can be enforced, and the spec says where that is rather than implying it is everywhere. A release **refuses to proceed**; a pull request gets a **visible failure a person can still merge past**, because nothing in this repository makes a check blocking @@ -84,20 +96,22 @@ Concretely, when this work is done: Seven steps, executed in the order below. Only some adjacencies are forced; the forced ones are named and the rest are free ([D18](artifacts/decision-log.md#d18-the-execution-order-is-a-partial-order-and-the-repair-precedes-the-correction)). -The binding constraints are: **the check is last**; **the release repair and the version correction are one unit**; -**the declaration deletion and the document correction are one unit**; and the work-items fix is independent of all of -it. - -The first unit is not merely an ordering. The release gate begins refusing releases the moment the release runs the -rule, which is step 3 — not step 7, where the rule merely becomes visible on pull requests -([D14](artifacts/decision-log.md#d14-the-release-runs-the-check-rather-than-restating-it)). Every plugin whose version -records disagree is a gap the gate names, and every plugin but one disagrees until step 4 corrects them. Shipping step 3 -without step 4 therefore stops every release in between, which is the same "a blocking rule arrives before the problems -are fixed" failure the check is sequenced last to avoid -([D1](artifacts/decision-log.md#d1-the-check-lands-last-because-a-check-that-blocks-everything-gets-disabled)). The two -steps land together, and the window does not exist +The binding constraints are: **the check is last**; **the release repair precedes the version correction**; **the +declaration deletion and the document correction are one unit, shipped in a single change**; and the work-items fix is +independent of all of it. + +The repair-before-correction constraint is an **ordering, not a unit**, and the difference is worth stating because this +specification briefly claimed otherwise. The claim was that the gate starts refusing releases at step 3, so shipping +step 3 without step 4 would freeze every release until the versions were corrected. That is false, and it is false +because of what step 3 actually does: a repaired release **corrects a stale version rather than refusing over it**. The +eight disagreements are gaps the release closes, not gaps it stops on, so the release that runs between step 3 and step +4 repairs them and proceeds. The freeze never happens ([D18](artifacts/decision-log.md#d18-the-execution-order-is-a-partial-order-and-the-repair-precedes-the-correction)). +What survives is the original ordering and its original reason: the repair goes first so the correction is durable. Step +4 exists because a correction made by hand is a correction that does not wait for a release +([D38](artifacts/decision-log.md#d38-the-repair-and-the-correction-are-ordered-not-united)). + ### Step 1: Publish the Linear plugin to channel two _Source position: 1._ @@ -111,17 +125,23 @@ channel's client resolves from ([Open item 2](#open-items)). Its new version rec one already publishes for it, so it is correct on arrival ([D22](artifacts/decision-log.md#d22-the-new-version-record-is-created-at-the-plugins-channel-one-version)). +A person writes this record, and that is the point rather than an inconvenience. The record carries the plugin's +storefront presence — what it is called, how it is described, what it is for — and none of that is derivable from +anything the repository already holds. This is the same boundary step 3's release stops at, met here by the one actor +who can cross it +([D36](artifacts/decision-log.md#d36-a-release-creates-what-it-can-derive-and-stops-at-what-must-be-authored)). + Correct on arrival is not the same as durable. This step adds a file inside the Linear plugin's own directory, which is exactly what obliges that plugin to bump at the next release — and until step 3 lands, a release moves the plugin's channel-one version without touching the channel-two record this step just created. Step 1 manufactures one instance of -the very drift step 4 repairs. The coupling is not dissolved; it is small, and the step 3 and 4 unit heals it along -with the other eight. +the very drift step 4 repairs. The coupling is not dissolved; it is small, and step 3's repair heals it along with the +other eight, whether by hand at step 4 or at the first release after step 3. This goes first because it is the only step where a person following the project's own instructions hits an error right now ([D11](artifacts/decision-log.md#d11-step-1-goes-first-because-it-is-a-live-broken-promise)). That reasoning holds on the assumption that channel two's client resolves from the default branch. If it resolves from the release tag, this -step reaches nobody until the next successful release, which the step 3 and 4 unit gates anyway — and going first buys -ordering clarity rather than a faster fix ([Open item 2](#open-items)). +step reaches nobody until the next release, and going first buys ordering clarity rather than a faster fix +([Open item 2](#open-items)). ### Step 2: Close the GitHub publisher's silent hole @@ -138,6 +158,13 @@ at all** — the whole file is examined before the first item is published, so a be preceded by items already created ([D3](artifacts/decision-log.md#d3-a-foreign-annotation-stops-the-run-before-anything-is-created)). +**Examine-first covers every heading the publisher cannot place, not only the ones that look foreign.** The reason is +that the publisher cannot tell the two apart until it has looked: a heading it fails to parse might be another tracker's +annotation in a shape it does not know, or it might be a hand-edited line with the wrong kind of dash. Those need the +same answer, because the cheaper answer — publish the items you understood, then complain — is the one that creates +issues in a file that may already have been published somewhere else +([D30](artifacts/decision-log.md#d30-accounted-for-is-defined-so-the-promise-is-not-circular)). + The protection lives at every layer that inspects a heading, not only the last one. The publisher's own repair pass recognizes a foreign annotation as a distinct category that it must not silently repair, so the annotation reaches the gate rather than being tidied away before it @@ -150,7 +177,7 @@ dependency ([D23](artifacts/decision-log.md#d23-step-2-is-a-distinct-concern-ret ### Step 3: Teach the release process about every target -_Source position: 6. Moved ahead of the version correction, and shipped together with it — see +_Source position: 6. Moved ahead of the version correction — see [D18](artifacts/decision-log.md#d18-the-execution-order-is-a-partial-order-and-the-repair-precedes-the-correction)._ The release process updates two of the four targets, and takes its list of plugins from one of the targets it also @@ -161,27 +188,58 @@ After this step, a release derives the set of plugins from what is actually in t ([D5](artifacts/decision-log.md#d5-the-check-derives-the-plugin-list-from-the-repository)) and brings all four targets up to date. -Bringing a target up to date includes **creating a record that does not exist yet**, not only updating one that has -fallen behind. This distinction is the difference between a release that repairs and a release that merely complains: -today's process can only write a version onto a record already present, so a plugin absent from a target stays absent -no matter how many releases run. A record created this way is written at the version the release is publishing for that -plugin, which for a plugin the release did not bump is the version it already has — so the record arrives correct and -agreeing rather than arriving and then needing a second pass +Bringing a target up to date has two halves, and today's release has neither. It **corrects a version that has fallen +behind**, including for a plugin it did not bump — today it writes a version only for the plugins it bumps, which is +exactly why a record that drifted stays drifted no matter how many releases run. And it **creates a record that does not +exist yet** — today's process can only write a version onto a record already present, so a plugin absent from a target +stays absent forever. The first half is why the drift is repairable at all; the second is why the Linear plugin's shape +is repairable ([D31](artifacts/decision-log.md#d31-the-release-creates-a-missing-target-at-the-version-it-is-publishing)). +**Creation reaches the two channel-two targets and stops there.** That is where the evidence is: a plugin missing from +channel two is the defect this work exists to fix, and it is live today. A plugin cannot be missing from channel one's +per-plugin record, because carrying one is part of what makes a directory a plugin +([D19](artifacts/decision-log.md#d19-a-plugin-and-the-targets-it-belongs-in-are-defined-positively)); a plugin missing +from channel one's listing has never happened, and is the one creation would have to author a description for. Both are +gaps the release refuses rather than closes +([D36](artifacts/decision-log.md#d36-a-release-creates-what-it-can-derive-and-stops-at-what-must-be-authored)). + +A created record carries the version the release is publishing for that plugin, which for a plugin the release did not +bump is the version it already has, read from that plugin's own channel-one record and never from a listing — a listing +is one of the things this rule exists to correct, so it cannot also be the authority +([D31](artifacts/decision-log.md#d31-the-release-creates-a-missing-target-at-the-version-it-is-publishing)). **The +release creates what it can derive and refuses what must be authored.** A plugin's storefront presence — its names, its +descriptions, the examples of what to ask it — is written by a person, and a release that invented it would be publishing +prose nobody wrote to a storefront people read. A plugin with no authored presence on channel two is therefore a gap the +release names and stops on, exactly like a listing entry with nothing behind it +([D36](artifacts/decision-log.md#d36-a-release-creates-what-it-can-derive-and-stops-at-what-must-be-authored)). + A release refuses to proceed when it finds a gap it cannot close: a listing naming a plugin that does not exist, a -record it cannot read, or a version it cannot make sense of. **The gate runs on the state being released — after the -release has brought all four targets up to date, before it asks anyone to approve publishing, and before it commits, -tags, pushes, or publishes anything.** Early enough that every action after it is still local and reversible; late -enough to judge what is actually being released rather than what preceded it; and before the person is asked to -authorize, because a gate that refuses after approval teaches people to distrust it +record it cannot read, a version it cannot make sense of, a plugin whose publishing version it cannot determine, or a +plugin whose presence on a channel it would have to author. **The gate runs on the state being released — after the +release has brought all four targets up to date, and before it commits, tags, pushes, or publishes anything.** Early +enough that every action after it is still local and reversible; late enough to judge what is actually being released +rather than what preceded it ([D24](artifacts/decision-log.md#d24-the-gate-runs-after-all-targets-are-written-and-before-anything-irreversible)). When it stops, it names every gap it found rather than the first ([D12](artifacts/decision-log.md#d12-a-missing-plugin-stops-the-release-and-every-gap-is-named)). -This is where the rule starts refusing things. That is why this step and step 4 are one unit: every plugin but one has -version records that disagree until step 4 corrects them, and a gate that is live against nine gaps stops every release -until they are closed. +The gate cannot promise to refuse before the operator has committed to anything, and the specification no longer implies +it can. A release asks the operator to confirm its version plan before it writes the targets, and the gate cannot run +until they are written — so a refusal always lands after someone has approved a plan. What the gate owes them is that +the refusal is cheap and complete: nothing has been published, and every gap is named at once rather than one per +attempt ([D24](artifacts/decision-log.md#d24-the-gate-runs-after-all-targets-are-written-and-before-anything-irreversible)). + +**Everything the release wrote travels into the commit it tags.** The release repairs four targets and today commits the +two it has always committed, which would leave the tag naming a state where channel two is still frozen, the repaired +records stranded uncommitted, and the next release refusing to start against the dirty tree they left. The state the +gate passed is the state that ships +([D37](artifacts/decision-log.md#d37-the-release-commits-every-target-it-writes)). + +**A release reports what it created.** Which plugin, which targets, at what version, in the same breath as its version +plan. The whole defect being repaired here is that something quietly stopped happening to what Han publishes; a repair +that quietly starts happening is the same shape wearing better clothes +([D39](artifacts/decision-log.md#d39-a-release-reports-what-it-created)). The release holds no copy of the rule. It runs the check and reports what the check says ([D14](artifacts/decision-log.md#d14-the-release-runs-the-check-rather-than-restating-it)). @@ -190,16 +248,22 @@ This step also corrects the documents that describe the release procedure, becau ([D21](artifacts/decision-log.md#d21-the-release-procedure-documents-are-owned-by-the-step-that-falsifies-them)). One exception is permanent and named: the bundle cannot be published to channel two, because that channel does not -support bundles. The rule knows this about that one plugin specifically and does not flag its absence, nor ask it to -agree with a channel-two record it does not have. The exception stops there. The bundle publishes a version in both of -channel one's records, so those two are held to agreement like any other plugin's — and more carefully than most, since -the bundle bumps on every release and the release tag is named after its version +support bundles. The rule knows this about that one plugin specifically and, on channel two, does not flag its absence, +does not ask it to agree with a record it does not have, and **does not create one for it**. That third verb matters as +much as the other two now that a release can write and not merely look: the bundle is the one plugin a helpful release +would publish to a channel that cannot install it, and the same exception that tells the rule not to look would keep the +mistake silent +([D6](artifacts/decision-log.md#d6-the-bundle-is-a-permanently-named-exception-on-the-second-channel)). + +The exception stops at channel two. The bundle publishes a version in both of channel one's records, so those two are +held to agreement like any other plugin's — and more carefully than most, since the bundle bumps on every release and the +release tag is named after its version ([D6](artifacts/decision-log.md#d6-the-bundle-is-a-permanently-named-exception-on-the-second-channel), [D20](artifacts/decision-log.md#d20-version-agreement-covers-every-record-that-publishes-a-version)). ### Step 4: Correct the frozen version numbers -_Source position: 3. Moved after the release repair, and shipped together with it as one unit._ +_Source position: 3. Moved after the release repair._ Channel two's published version numbers have not moved since the day they were created, so that channel never offers anyone an update @@ -208,14 +272,21 @@ anyone an update After this step, each plugin's channel-two version matches the version channel one publishes for that same plugin ([D10](artifacts/decision-log.md#d10-the-two-channels-publish-one-version-per-plugin)). -Because the release process is repaired first, this correction is durable: the very next release keeps it correct -rather than re-freezing it. Had this step come first, any release cut before the repair would have undone it. And -because the repair ships together with this step rather than merely ahead of it, the reverse window does not open -either — the gate never spends a release refusing the disagreements this step is on its way to fix +Because the release process is repaired first, this correction is durable: the very next release keeps it correct rather +than re-freezing it. Had this step come first, any release cut before the repair would have undone it ([D18](artifacts/decision-log.md#d18-the-execution-order-is-a-partial-order-and-the-repair-precedes-the-correction)). -This step closes nine gaps, not eight. Eight plugins carry a channel-two version that has fallen behind; the ninth is -the Linear plugin, whose record step 1 created and whose own arrival obliged it to bump. +This step is worth doing by hand even though step 3 makes a release do it. Once the repair lands, the first release +corrects these numbers whether or not this step ever runs — so what this step buys is that the numbers are right on +merge rather than whenever someone next cuts a release. That is a smaller claim than "the correction requires this +step", and it is the true one +([D38](artifacts/decision-log.md#d38-the-repair-and-the-correction-are-ordered-not-united)). + +This step closes eight gaps. Eight plugins carry a channel-two version that has fallen behind, and the Linear plugin's +record arrives agreeing because step 1 created it at the version channel one already publishes. A ninth gap opens only +if a release is cut between step 1 and step 3, which moves the Linear plugin's channel-one version while the record step +1 created stays put. That is a contingency rather than a count +([D38](artifacts/decision-log.md#d38-the-repair-and-the-correction-are-ordered-not-united)). ### Step 5: Delete the three untrue dependency declarations @@ -261,10 +332,25 @@ corrected set. are not.** This is the rule the spec was applying without stating: it is why the contributor guide's untrue universal claim is in scope and why the tutorial's untrue version promise is in scope, while the stale enumerations elsewhere in the repository stay out -([D33](artifacts/decision-log.md#d33-an-already-false-statement-inside-a-rewritten-passage-is-corrected)). One live -instance is known: the orientation document's description of the bundle's own dependencies omits one of them, is not -falsified by this work, and sits between two lines this step must edit. It is corrected, because the editor is not -merely open — the surrounding sentences are being rewritten. +([D33](artifacts/decision-log.md#d33-an-already-false-statement-inside-a-rewritten-passage-is-corrected)). + +**A passage is the paragraph.** The boundary needs stating because the rule is otherwise argued into either uselessness +or an audit. "Sentence" is narrower than what a person actually rewrites; "document" would pull in every stale line in a +file this step happens to open, which is the open-ended audit this specification refuses. The paragraph is the unit that +gets rewritten, so it is the unit the rule reaches +([D33](artifacts/decision-log.md#d33-an-already-false-statement-inside-a-rewritten-passage-is-corrected)). + +The known live instance does **not** qualify under that rule, and saying so is the point. The orientation document's +description of the bundle's own dependencies omits one of them. It is already false, is not falsified by this work, and +sits in its own paragraph between two paragraphs this step rewrites. Adjacency is not the test, so the paragraph rule +leaves it alone. + +It is corrected anyway, by a route it does qualify under. That paragraph is one of the document's dependency +enumerations, and this step is already rewriting the document's dependency enumerations to drop their counts and name +the manifests as the record. Applying that remedy to an enumeration means checking it against the manifests, and an +enumeration that disagrees with the record is corrected by the act of applying the remedy — not by being nearby +([D26](artifacts/decision-log.md#d26-corrected-documents-state-the-rule-and-point-at-the-record)). The distinction is +worth the words: one route corrects it for a reason, and the other corrects it because the editor was open. Manifest descriptions are documents and are in scope. Manifest dependency declarations are the record itself, not a description of it, and are step 5's business @@ -298,11 +384,17 @@ because that claim is already false and keeping it would create a maintenance ob _Source position: 7. Last, and this is the one hard ordering constraint the source plan argued for._ Only now does the rule become **visible on a pull request**. The rule itself has existed since step 3, because the -release runs it, and it has been refusing releases since then +release runs it, and it has been refusing releases since then over the gaps a release cannot close ([D14](artifacts/decision-log.md#d14-the-release-runs-the-check-rather-than-restating-it)). This step puts the same answer in front of a contributor before a maintainer meets it at release time. It verifies that every plugin in the repository appears in every target it belongs in, and that a plugin's version records agree. +The two surfaces ask one question and get different answers, and this is the point rather than an inconsistency. On a +pull request the rule reports what is wrong **now**, including drift a release would have repaired on its own. On a +release the rule reports what is wrong **after** the release has repaired everything it can, so what is left is only +what a person must decide. The contributor sees the gap; the maintainer sees the residue +([D38](artifacts/decision-log.md#d38-the-repair-and-the-correction-are-ordered-not-united)). + It runs on every pull request, and additionally on the machines of contributors who have installed the optional local hooks ([D4](artifacts/decision-log.md#d4-the-check-blocks-on-every-pull-request-and-runs-locally-where-hooks-are-installed)). @@ -319,39 +411,74 @@ introduced it, which is where it is cheapest to fix and where the person who cau Making it genuinely blocking is a change to repository settings that no step here owns, and the spec names that gap rather than assuming it away ([Open item 3](#open-items)). -Because steps 1 through 6 have already landed, the check is green on the day it arrives and stays green -([D1](artifacts/decision-log.md#d1-the-check-lands-last-because-a-check-that-blocks-everything-gets-disabled)). +Because steps 1 through 6 have already landed, the check is green on the day it arrives. It does not stay green by +construction, and claiming it would reinstate the assumption this specification spent its enforcement claim correcting: +the check goes red on the pull request that introduces a gap, a person may merge past it, and the default branch then +carries a red check until the next release repairs the gap. That is D1's restated failure mode arriving where it +actually lives — not a check that blocks everything and gets switched off, but a signal that stays red and stops being +read +([D1](artifacts/decision-log.md#d1-the-check-lands-last-because-a-check-that-blocks-everything-gets-disabled), +[D32](artifacts/decision-log.md#d32-the-guarantee-is-stated-per-surface-because-only-the-release-can-refuse)). There is no disable switch, deliberately ([D28](artifacts/decision-log.md#d28-the-check-ships-with-no-disable-switch)). ## Alternate flows and states -**The rule lands before the problems are fixed.** This is the failure the ordering exists to prevent, and it has two -faces. On the release surface it is real and immediate: the rule starts refusing releases at step 3, so shipping step 3 -without step 4 stops every release until the versions are corrected. That is why the two are one unit. On the -pull-request surface the classic version — a correct check that blocks everything on day one until someone disables it -— cannot occur here, because nothing on that surface blocks +**The rule lands before the problems are fixed.** This is the failure the ordering exists to prevent, and neither of its +two faces is the one this specification first described. + +On the release surface it does not occur, because the rule that arrives at step 3 arrives attached to a release that +repairs. The eight disagreements are gaps the release closes rather than refuses, so a release cut between step 3 and +step 4 fixes them and proceeds. This specification previously claimed the opposite — that the gate would freeze every +release in the window — and made steps 3 and 4 one unit to close a window that was never open +([D38](artifacts/decision-log.md#d38-the-repair-and-the-correction-are-ordered-not-united)). + +On the pull-request surface the classic version — a correct check that blocks everything on day one until someone +disables it — cannot occur either, because nothing on that surface blocks ([T2](artifacts/feature-technical-notes.md#t2-a-pull-request-check-blocks-a-merge-only-where-a-required-status-check-demands-it)). -The failure mode there is quieter and no less real: a check that is permanently red is a check people learn to scroll -past, and a signal nobody reads protects nothing +The failure mode there is quieter and is the one that is actually reachable: a check that is permanently red is a check +people learn to scroll past, and a signal nobody reads protects nothing ([D1](artifacts/decision-log.md#d1-the-check-lands-last-because-a-check-that-blocks-everything-gets-disabled)). -**A release runs while a plugin is missing from a target.** The release creates the missing record at the version it is -publishing for that plugin and proceeds -([D31](artifacts/decision-log.md#d31-the-release-creates-a-missing-target-at-the-version-it-is-publishing)). This is -the ordinary path, not the failure path: the whole point of the repair is that a release closes this gap rather than -reporting it. +**A release runs while a plugin is missing from a channel-two target.** The release creates the missing record at the +version it is publishing for that plugin, reports that it did, and proceeds +([D31](artifacts/decision-log.md#d31-the-release-creates-a-missing-target-at-the-version-it-is-publishing), +[D39](artifacts/decision-log.md#d39-a-release-reports-what-it-created)). This is the ordinary path, not the failure +path: the whole point of the repair is that a release closes this gap rather than reporting it. A plugin missing from a +**channel-one** target is the other case, and it is a stop rather than a repair +([D36](artifacts/decision-log.md#d36-a-release-creates-what-it-can-derive-and-stops-at-what-must-be-authored)). + +**A release runs while a plugin's version has fallen behind on a target.** The release writes the version it is +publishing for that plugin onto the record and proceeds, whether or not it bumped that plugin this release. This is also +the ordinary path, and it is what makes step 4's correction durable rather than a thing that re-freezes +([D38](artifacts/decision-log.md#d38-the-repair-and-the-correction-are-ordered-not-united)). **A release meets a gap it cannot close.** A listing naming a plugin that is not in the repository, a record it cannot -read, or a version value it cannot make sense of. The release stops after bringing the targets up to date, before -asking anyone to approve, and before committing +read, a version value it cannot make sense of, a plugin whose publishing version it cannot determine, or a plugin whose +presence on a channel would have to be authored. The release stops after bringing the targets up to date and before +committing ([D24](artifacts/decision-log.md#d24-the-gate-runs-after-all-targets-are-written-and-before-anything-irreversible)), naming every gap it found rather than the first ([D12](artifacts/decision-log.md#d12-a-missing-plugin-stops-the-release-and-every-gap-is-named)). Nothing is tagged, -pushed, or published. **Recovery is not discarding local changes** — that reproduces the same stop, because the gap is -in the repository and not in the release's uncommitted work. The gap must be corrected and committed, and only then can -the release run again, because a release refuses to start from a dirty tree at all +pushed, or published. + +**Recovery from that stop is two acts, in order, and the order is what makes it safe.** First the release's own local +work is discarded — everything it wrote and everything it created, because a created file the release leaves behind is +untracked, survives a careless cleanup, and keeps the tree dirty. Then the gap is corrected and committed on its own. +Only then does the release run again, and it plans the release from scratch +([D34](artifacts/decision-log.md#d34-a-gate-stop-costs-a-separate-commit-because-the-release-refuses-a-dirty-tree)). + +The order is not bookkeeping. Committing the release's half-finished version writes together with the gap correction +makes those versions look like bumps a person made on purpose during development, and the release's own plan +confirmation stops asking about a plugin whose version already moved. The recovery from a gate stop would then quietly +publish versions nobody approved — a step of the release deciding something a person was supposed to decide, which is +the exact class of defect this work exists to end +([D34](artifacts/decision-log.md#d34-a-gate-stop-costs-a-separate-commit-because-the-release-refuses-a-dirty-tree)). + +**The gap must be corrected where the next release will see it.** A release may be cut from a branch that is not the +default one, and a gap corrected only on that branch is a gap that is still there for everyone else. The stop is closed +when the correction reaches the branch releases are cut from, not when the release in front of you gets past it ([D34](artifacts/decision-log.md#d34-a-gate-stop-costs-a-separate-commit-because-the-release-refuses-a-dirty-tree)). **A release fails partway through writing the four targets.** Every write happens before anything irreversible, so the @@ -375,24 +502,42 @@ signal being advisory costs lateness rather than correctness. The signal is only contributor guide to name all four targets rather than one ([D21](artifacts/decision-log.md#d21-the-release-procedure-documents-are-owned-by-the-step-that-falsifies-them)). -**A release is cut from a branch where a step has not landed.** What stops it is the release's own gate, not a -pull-request check that may never have run — the release process permits cutting from a non-default branch, and a branch -with no pull request gets no pipeline run. This is the general case rather than a corner: the release gate is the only -surface that refuses anything, so it is the only surface a missing step is caught on -([D32](artifacts/decision-log.md#d32-the-guarantee-is-stated-per-surface-because-only-the-release-can-refuse)). +**A release is cut from a branch where a step has not landed.** Mostly nothing stops it, and the specification says so +rather than claiming a backstop it does not have. A branch missing step 3 carries the old release, which has no gate at +all and publishes channel one exactly as it does today. A branch missing steps 1 or 4 is repaired by the release rather +than refused, which is the repair working. A branch missing steps 5, 6, or 7 releases cleanly, because the gate does not +inspect dependency declarations, documents, or its own existence. The release process permits cutting from a non-default +branch, and a branch with no pull request gets no pipeline run, so there is no second surface either +([D32](artifacts/decision-log.md#d32-the-guarantee-is-stated-per-surface-because-only-the-release-can-refuse)). The +gate's job is the gaps a release cannot close, not the steps of this plan +([D38](artifacts/decision-log.md#d38-the-repair-and-the-correction-are-ordered-not-united)). + +**A plugin is being removed from the suite.** A removal that lands whole — the directory and both channels' entries +gone together — is not a state the rule ever sees. A removal that lands half-finished is, and it is the same four-file +mistake this work exists to prevent, pointed the other way. The rule does not distinguish "never published here" from +"published here until recently", so a release meeting a half-removed plugin would helpfully create back the records the +removal deleted. **A plugin the repository still carries is a plugin the rule expects in every target it belongs in**, +which means a removal is finished or it is not: a directory that remains is a plugin, and its absence from a target it +belongs in is a gap the release closes. Removing a plugin means removing the directory +([D40](artifacts/decision-log.md#d40-a-half-finished-removal-is-not-a-state-the-release-guesses-at)). ## Edge cases and failure modes | Case | Behavior | | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | -| The bundle is absent from channel two | Not flagged, and the cross-channel comparison does not apply to it ([D6](artifacts/decision-log.md#d6-the-bundle-is-a-permanently-named-exception-on-the-second-channel)) | +| The bundle is absent from channel two | Not flagged, **not created**, and the cross-channel comparison does not apply to it. The creation verb matters as much as the other two: it is the one plugin a repairing release would publish to a channel that cannot install it ([D6](artifacts/decision-log.md#d6-the-bundle-is-a-permanently-named-exception-on-the-second-channel)) | | The bundle's two channel-one records disagree with each other | Check fails, like any other plugin. The bundle's exemption is from the comparison between channels, not from agreement within one. It bumps every release and its version names the release tag, so this is the most-exercised pair in the suite ([D6](artifacts/decision-log.md#d6-the-bundle-is-a-permanently-named-exception-on-the-second-channel), [D20](artifacts/decision-log.md#d20-version-agreement-covers-every-record-that-publishes-a-version)) | -| A plugin is missing from any target it belongs in, including just one of two channels | A release creates the missing record and proceeds ([D31](artifacts/decision-log.md#d31-the-release-creates-a-missing-target-at-the-version-it-is-publishing)). On a pull request the check reports it, naming the plugin and every target it is missing from. This is the shape of the defect that motivated the work ([D19](artifacts/decision-log.md#d19-a-plugin-and-the-targets-it-belongs-in-are-defined-positively)) | +| A plugin is missing from a **channel-two** target it belongs in | A release creates the missing record, reports that it did, and proceeds ([D31](artifacts/decision-log.md#d31-the-release-creates-a-missing-target-at-the-version-it-is-publishing), [D39](artifacts/decision-log.md#d39-a-release-reports-what-it-created)). On a pull request the check reports it, naming the plugin and every target it is missing from. This is the shape of the defect that motivated the work ([D19](artifacts/decision-log.md#d19-a-plugin-and-the-targets-it-belongs-in-are-defined-positively)) | +| A plugin is missing from a **channel-one** target it belongs in | Check fails; the release stops rather than creating it. Creation is committed where the evidence is, and no plugin has ever been missing from a channel-one target — carrying a channel-one record is part of what makes a directory a plugin, and creating a channel-one listing entry would mean authoring the description that entry carries ([D36](artifacts/decision-log.md#d36-a-release-creates-what-it-can-derive-and-stops-at-what-must-be-authored)) | +| A plugin has no authored presence on a channel it belongs in | Check fails; the release stops rather than inventing one. A storefront presence is prose a person wrote, and a release that composed it would be publishing writing nobody authored to a page people read ([D36](artifacts/decision-log.md#d36-a-release-creates-what-it-can-derive-and-stops-at-what-must-be-authored)) | | A plugin is in a storefront listing but not in the repository | Check fails. A listing entry resolving to nothing breaks the install-succeeds promise directly, and it is the one membership gap a release must not "fix" by itself, since the remedy is a person deciding whether the plugin or the entry is the mistake ([D29](artifacts/decision-log.md#d29-a-listing-entry-with-no-plugin-behind-it-fails-the-check)) | -| A plugin's version records disagree | Check fails, naming the plugin and every disagreeing record ([D20](artifacts/decision-log.md#d20-version-agreement-covers-every-record-that-publishes-a-version)) | +| A plugin's version records disagree | On a pull request the check fails, naming the plugin and every disagreeing record. On a release it is repaired rather than reported: the release writes the publishing version to every record it can, so a disagreement it caused is the only one the gate can still see ([D20](artifacts/decision-log.md#d20-version-agreement-covers-every-record-that-publishes-a-version), [D38](artifacts/decision-log.md#d38-the-repair-and-the-correction-are-ordered-not-united)) | +| A plugin's publishing version cannot be determined | Check fails; the release stops rather than guessing. A plugin the release cannot assign a version to is a gap creation cannot close, alongside a dangling listing entry and an unreadable record ([D36](artifacts/decision-log.md#d36-a-release-creates-what-it-can-derive-and-stops-at-what-must-be-authored)) | +| A storefront listing cannot be read at all | Surfaced and blocking for that whole channel, never read as "every plugin is missing from this target". A listing is one shared file covering every plugin, so a parse failure would otherwise route the entire channel into the create-path and overwrite the file with regenerated entries — the loudest possible version of the silent defect this work exists to end ([D35](artifacts/decision-log.md#d35-an-unreadable-record-or-version-is-surfaced-not-skipped)) | | A record cannot be read at all | Surfaced and blocking, never skipped. A record that fails to parse must not silently drop its plugin from the set being checked — a rule applied to a set that quietly excludes the broken member is the same invisible-by-construction defect this work exists to end ([D35](artifacts/decision-log.md#d35-an-unreadable-record-or-version-is-surfaced-not-skipped)) | | A record publishes a version, but the value is absent, empty, or not a version | Check fails, naming it, exactly as a disagreement does. Two unreadable values are never treated as agreeing with each other ([D35](artifacts/decision-log.md#d35-an-unreadable-record-or-version-is-surfaced-not-skipped)) | | A plugin has a manifest and no skills | Valid. The bundle is permanently in this state, so "has skills" is not part of what makes a directory a plugin ([D19](artifacts/decision-log.md#d19-a-plugin-and-the-targets-it-belongs-in-are-defined-positively)) | +| A plugin's directory remains but its records were deleted | Treated as a plugin missing from those targets, not as a removal in progress. The rule does not guess intent from absence, so a removal is finished or it is not ([D40](artifacts/decision-log.md#d40-a-half-finished-removal-is-not-a-state-the-release-guesses-at)) | | A work item's heading is malformed in any way the publisher does not recognize | Surfaced, never silently passed over. "Accounted for" means every heading is either published, skipped-and-counted, or surfaced ([D30](artifacts/decision-log.md#d30-accounted-for-is-defined-so-the-promise-is-not-circular)) | | A work-items file mixes annotated and unannotated items for the same tracker | Unchanged: annotated items skipped and counted, unannotated items published | | Two trackers' annotations are indistinguishable from each other | Out of scope here; the trap remains and is specified separately ([D2](artifacts/decision-log.md#d2-step-2-closes-the-silent-hole-only-annotation-namespacing-is-separate)) | @@ -403,15 +548,22 @@ surface that refuses anything, so it is the only surface a missing step is caugh - **The release process and the repository.** The release reads the set of plugins from the repository rather than from a target it also writes, so a stale listing can no longer hide a plugin from it ([D5](artifacts/decision-log.md#d5-the-check-derives-the-plugin-list-from-the-repository)). -- **The release process and both storefronts.** The release brings all four targets up to date — creating what is - missing, updating what is stale — before it does anything irreversible, and before it asks anyone to approve +- **The release process and both storefronts.** The release brings all four targets up to date — updating a version that + has fallen behind, and creating a channel-two record that does not exist — before it does anything irreversible ([D24](artifacts/decision-log.md#d24-the-gate-runs-after-all-targets-are-written-and-before-anything-irreversible), - [D31](artifacts/decision-log.md#d31-the-release-creates-a-missing-target-at-the-version-it-is-publishing)). + [D31](artifacts/decision-log.md#d31-the-release-creates-a-missing-target-at-the-version-it-is-publishing), + [D36](artifacts/decision-log.md#d36-a-release-creates-what-it-can-derive-and-stops-at-what-must-be-authored)). +- **The release process and the commit it tags.** Every target the release writes is in the commit it tags, so the state + the gate approved is the state that ships. Without this the tag names a repository where channel two is still frozen, + the repaired records sit uncommitted, and the next release refuses to start against the tree they dirtied + ([D37](artifacts/decision-log.md#d37-the-release-commits-every-target-it-writes)). - **The check and the release process.** One rule, one bearer. The release runs the check and reports its answer rather than restating the rule in its own words, so the two cannot drift ([D14](artifacts/decision-log.md#d14-the-release-runs-the-check-rather-than-restating-it)). One consequence is easy to miss: because the release runs the rule, the rule is refusing releases from step 3 onward, four steps before it - appears on a pull request. + appears on a pull request. It refuses over a narrower set than the check reports, because by the time the release runs + the rule it has already repaired everything the rule would otherwise have caught it doing + ([D38](artifacts/decision-log.md#d38-the-repair-and-the-correction-are-ordered-not-united)). - **The check and the pull-request pipeline.** The check runs on every pull request and reports a failure a person can merge past, because nothing here makes a check required ([T2](artifacts/feature-technical-notes.md#t2-a-pull-request-check-blocks-a-merge-only-where-a-required-status-check-demands-it)). @@ -422,10 +574,12 @@ surface that refuses anything, so it is the only surface a missing step is caugh - **The three work-items publishers and the shared work-items file.** All three read and annotate the same file. Step 2 changes only how one of them responds to annotations it does not recognize; it does not change what any of them writes. -- **Step 5 and step 6.** They ship together +- **Step 5 and step 6.** They ship together, in a single change rather than in two that follow each other closely ([D18](artifacts/decision-log.md#d18-the-execution-order-is-a-partial-order-and-the-repair-precedes-the-correction)). The intermediate state — declarations deleted, documents still narrating them — is exactly what step 6 exists to - prevent. + prevent, and "one unit" only prevents it if nothing can land between them. Nothing in this repository enforces that, + so it is a commitment about how the work is shipped rather than a property of the work + ([D38](artifacts/decision-log.md#d38-the-repair-and-the-correction-are-ordered-not-united)). ## User interactions @@ -439,9 +593,17 @@ The people who experience this feature are maintainers and contributors at a ter possible because "belongs in" is defined rather than left to the exception ([D19](artifacts/decision-log.md#d19-a-plugin-and-the-targets-it-belongs-in-are-defined-positively)). The message is the same; the consequence is not. On a pull request it informs, and the contributor may proceed anyway. On a release - it refuses. + it refuses — over a smaller set, because a release repairs on its way to the gate and a pull request has nothing that + repairs ([D38](artifacts/decision-log.md#d38-the-repair-and-the-correction-are-ordered-not-united)). +- **Release repair.** Names what it created and what it corrected — which plugin, which targets, at what version — + alongside the version plan it already reports. A release that quietly starts writing to what Han publishes is the same + shape as a release that quietly stopped, which is the defect being repaired + ([D39](artifacts/decision-log.md#d39-a-release-reports-what-it-created)). - **Release stop.** Names every gap it found before stopping, not just the first one, so a maintainer learns the full set - in one run ([D12](artifacts/decision-log.md#d12-a-missing-plugin-stops-the-release-and-every-gap-is-named)). + in one run ([D12](artifacts/decision-log.md#d12-a-missing-plugin-stops-the-release-and-every-gap-is-named)). The stop + always lands after the operator has approved a version plan, because the gate cannot run until the targets are written + and the plan is confirmed before that. What the stop owes them is that nothing was published and the whole set is named + at once ([D24](artifacts/decision-log.md#d24-the-gate-runs-after-all-targets-are-written-and-before-anything-irreversible)). - **Work-items format error.** Names the specific work items whose annotations were not recognized and what they appear to be annotated by, so the person can tell "already published to another tracker" from "file is malformed". This requires "malformed" to be a detectable category rather than a residue @@ -501,6 +663,18 @@ The people who experience this feature are maintainers and contributors at a ter release deviation. The release gate is the signal, and the drift persisted for eleven releases precisely because nothing asked the question at all — not because nobody was watching a graph. **Reopening trigger:** drift recurs despite the gate, meaning the gate is asking the wrong question. +- **Distinguishing "this work-items file has nothing in it" from "you passed the wrong file".** A file with no + recognizable headings satisfies the accounted-for promise trivially and reports nothing published and nothing skipped. + That is a real silence, and it is the only one this specification is choosing to keep — because nobody has pointed the + publisher at the wrong file, no incident names it, and the run already reports the two zeroes a person would notice. + **Reopening trigger:** someone publishes from the wrong file, or a run of zeroes is mistaken for a run that worked. +- **Confirming a plugin's first publication to a channel before a release makes it installable.** Creation makes a + directory publicly installable with no sign-off, which is a larger act than the version bump that does get confirmed. + It is deferred because the release now reports what it created + ([D39](artifacts/decision-log.md#d39-a-release-reports-what-it-created)), which is the strictly simpler thing that + satisfies the same concern, and because the case it guards against — a half-built plugin merged to the default branch + and released before anyone noticed — has never happened. **Reopening trigger:** a release publishes a plugin that was + not ready, or a maintainer asks to hold a plugin's directory on the default branch while keeping it unpublished. ## Open items From 5512a8d3c0f1dc27cb70fa38f99758a2b7025ab5 Mon Sep 17 00:00:00 2001 From: River Lynn Bailey <river.bailey@testdouble.com> Date: Thu, 16 Jul 2026 14:06:07 -0600 Subject: [PATCH 04/22] docs(plans): record R2 review findings and iteration history Adds F44-F66 to the findings file and the R2 round entry to the iteration history, and brings the spec's Summary and Review History up to date. R2 raised 17 major and 4 minor findings and rejected 2. Nearly all trace to D31: the release's ability to repair was granted late in R1 and never carried into the six neighbouring decisions it changed. Two findings were rejected on evidence: D18's numeral list is stated in the source plan's numbering and is correct, and han-atlassian's han-communication declaration is real (README.md:84-85 documents it), so step 5 remains three. --- .../artifacts/decision-log.md | 2 +- .../artifacts/review-findings.md | 687 ++++++++++++++++++ .../artifacts/review-iteration-history.md | 112 +++ .../feature-specification.md | 81 ++- 4 files changed, 854 insertions(+), 28 deletions(-) diff --git a/docs/plans/han-publishing-cleanup/artifacts/decision-log.md b/docs/plans/han-publishing-cleanup/artifacts/decision-log.md index 87fec859..a9642413 100644 --- a/docs/plans/han-publishing-cleanup/artifacts/decision-log.md +++ b/docs/plans/han-publishing-cleanup/artifacts/decision-log.md @@ -608,7 +608,7 @@ documents, turn on the check. The binding constraints are named; the rest are fr > been returned to what this entry always said. Recorded here because the R1 finding's resolution named four > specification sections and no decision, which is how a decision and the document citing it came to disagree for a > round ([D38](#d38-the-repair-and-the-correction-are-ordered-not-united), -> [F44](review-findings.md#f44-the-release-repairs-disagreements-so-the-freeze-the-3-4-unit-closes-never-happens), +> [F44](review-findings.md#f44-the-release-repairs-disagreements-so-the-freeze-the-34-unit-closes-never-happens), > [F53](review-findings.md#minor-edits)). **Rationale.** Two problems with the original total order. diff --git a/docs/plans/han-publishing-cleanup/artifacts/review-findings.md b/docs/plans/han-publishing-cleanup/artifacts/review-findings.md index 163db642..183eb0c9 100644 --- a/docs/plans/han-publishing-cleanup/artifacts/review-findings.md +++ b/docs/plans/han-publishing-cleanup/artifacts/review-findings.md @@ -519,6 +519,674 @@ cases. **Changed in tech-notes:** — +### F44: The release repairs disagreements, so the freeze the 3+4 unit closes never happens + +**Agent:** `junior-developer` (JD-101), with `devops-engineer` (DOR-R2-03) reaching the same conclusion from the gate's +side and (DOR-022) from the branch-cut flow's side. + +**Category:** two resolutions from the same round contradicting each other + +**Finding.** R1 produced F29 (steps 3 and 4 become one unit) and F30 (the release creates rather than only detects) in +the same round, and never checked them against each other. F29's argument: the release runs the rule, so the gate is live +from step 3; every plugin but one has disagreeing version records until step 4 corrects them; a release cut in the window +therefore hard-stops, freezing releases until the versions are fixed. + +F30 falsifies the premise. A repaired release **corrects a stale version rather than refusing over it** — the spec's own +Coordinations says "creating what is missing, updating what is stale". The gate runs after the writes (D24), so by the +time it looks, the eight disagreements are gone: the release closed them. Shipping step 3 without step 4 repairs the +drift rather than freezing anything. The window F29 structurally closed was never open. + +The spec had the contradiction on the page. Step 3 enumerated the gaps a release cannot close three times — a listing +naming a plugin that does not exist, a record it cannot read, a version it cannot parse — omitting version disagreement +from all three, while ten lines below claiming "a gate that is live against nine gaps stops every release until they are +closed." + +Writing the publishing version onto a disagreeing record and writing it onto a missing one are the same act. If the +release does the second, there is no principled reason it does not do the first. + +**Evidence considered.** codebase — `.claude/skills/han-release/SKILL.md:230` acts only "For every plugin whose `target` +differs from its `current`" and `:237-238` skips the rest; that skip is exactly what step 3 removes when it commits the +release to bringing every target up to date. codebase — eight plugins disagree today, `han-communication` agrees, and +`han-linear` has no channel-two record. Verified independently by `evidence-based-investigator` (C1) and by direct read. + +**Resolution.** The user confirmed that a release overwrites a stale version record for a plugin it did not bump, and +chose to restate the unit rather than narrow the repair. Steps 3 and 4 revert to an **ordering**, which is what D18 has +said all along — "the release repair precedes the version correction". D18 needed no edit; the spec's R1 change was the +error, and it cited D18 for a claim D18 never made (F53). Step 4 keeps its place on a smaller and true claim: it makes +the numbers right on merge rather than at the next release. Recorded as D38, which also states the consequence that the +two surfaces now answer differently — a pull request reports drift a release would have repaired, because a pull request +has nothing that repairs. + +**Resolved by:** user input (option: yes, the release overwrites; restate the unit), on evidence. + +**Raised in round:** R2 + +**Changed in plan:** Primary flow (binding constraints, Step 1, Step 3, Step 4, Step 7), Alternate flows and states, +Edge cases and failure modes, Coordinations, User interactions, Summary + +**Changed in decision log:** D38 added; D18 annotated (vindicated, no edit); D24 corrected + +**Changed in tech-notes:** — + +### F45: The release commits two of the four targets, so the gate certifies a state that is never released + +**Agent:** `devops-engineer`, twice independently (DOR-018 and DOR-R2-01, from two separate passes). + +**Category:** behavioral commitment the step does not deliver + +**Finding.** The spec commits the release to writing four targets and to a gate that judges the written state. It never +says what becomes of the writes. The release's commit stages an enumerated list of file classes, all on channel one, so +the real sequence is: write four targets, gate passes on the four-target state, commit two of them, tag, push. + +Four consequences, each silent: + +- **The tag does not contain the fix.** Under Open item 2's adverse branch (clients resolve from the tag), the entire + repair reaches nobody while the gate reports green. +- **The default branch goes red and stays red.** After one release, channel one is bumped and channel two is untouched — + which is D1's restated failure mode arriving via the repair rather than the ordering. +- **The next release hard-stops.** The uncommitted channel-two writes leave the tree dirty, surfacing one release removed + from the cause. +- **A creation-only release commits nothing at all.** The `han-linear` shape — the motivating case — stages nothing and + skips its own commit, tagging an unchanged tree. + +The gate cannot catch any of it by construction: it runs before the commit, so it inspects a tree the release then +declines to publish. This is the original defect (the release cannot see the targets it does not touch) relocated from +the write step to the commit step, shipping inside the fix. + +**Evidence considered.** codebase — `.claude/skills/han-release/SKILL.md:326-328` stages `CHANGELOG.md`, +`.claude-plugin/marketplace.json`, and every `{source}/.claude-plugin/plugin.json` the version step changed; both +channel-two targets absent. Verified directly during consolidation rather than taken from the agents. `:328` skips the +commit "if nothing is staged (… **unlikely**)", a parenthetical creation retires. `:72-74` hard-stops on a dirty tree. +`:333-335` tags the release commit. + +**Resolution.** One behavioral sentence: every target the release writes travels into the commit it tags, so the state +the gate approved is the state that ships. The staging list is the implementation plan's business. Recorded as D37. + +**Resolved by:** evidence. + +**Raised in round:** R2 + +**Changed in plan:** Outcome, Primary flow (Step 3), Coordinations + +**Changed in decision log:** D37 added + +**Changed in tech-notes:** — + +### F46: A created record carries content D31 says it does not, and "membership and nothing more" is false + +**Agent:** `adversarial-validator` (V1) and `junior-developer` (JD-103), convergent. + +**Category:** behavioral commitment resting on a false premise about the artifact + +**Finding.** D31 describes creation as writing a version, and describes creating a channel-two listing entry as "adding +the plugin's membership **and nothing more**". Both undersell the artifacts, and the second is false. + +- A channel-two per-plugin record carries `keywords` and an `interface` block — `displayName`, `shortDescription`, + `longDescription`, `developerName`, `category`, `capabilities`, `websiteURL`, and a `defaultPrompt` array of example + prompts. None derives from anything the repository holds: channel one carries a single `description` string, never + decomposed into short and long forms or prompts. +- A channel-two listing entry carries `"policy": {"installation": "AVAILABLE", "authentication": "ON_INSTALL"}` — the + field deciding whether the plugin is installable at all, which is the Outcome's headline promise sitting in the field + D31 called empty of everything but membership. +- A channel-one listing entry carries an authored `description`, which **D25 already classifies as a document**. So D31 + had an unattended release authoring user-facing prose mid-run. + +This is F33's error class — a decision exempting or committing something on a false premise about what the record +carries — reintroduced by R1 in a new location. It also breaks the spec's own parallel: step 1 creates the Linear +plugin's channel-two record **by hand**, where a person writes that prose. D31 gave the same act to a release with no +person. + +**Evidence considered.** codebase — verified directly during consolidation: `han-core/.codex-plugin/plugin.json` carries +the full interface block; `han-core/.claude-plugin/plugin.json` carries only `name`, `description`, `version`, +`dependencies`; every entry in `.agents/plugins/marketplace.json` carries the installation and authentication policy; +every entry in `.claude-plugin/marketplace.json` carries a description. + +**Resolution.** The release creates what it can derive and refuses what must be authored. A plugin with no authored +storefront presence is a gap creation cannot close — a gate stop, joining D29's dangling entry as "a person decides". +Step 1 still authors the Linear plugin's record by hand, which the spec now names as the same boundary met by the one +actor who can cross it. Recorded as D36. The Channels-and-targets glossary gains a bullet naming what a target carries +besides a version, since the glossary's "a place that states a plugin's published version" is what made the error easy. + +**Resolved by:** user input (option: narrow D31 — unauthored presence is a gate stop), on evidence. + +**Raised in round:** R2 + +**Changed in plan:** Channels and targets, Outcome, Primary flow (Step 1, Step 3), Alternate flows and states, Edge cases +and failure modes, Coordinations + +**Changed in decision log:** D36 added; D31 corrected + +**Changed in tech-notes:** — + +### F47: Creation is committed on all four targets on evidence that exists for two + +**Agent:** `junior-developer` (JD-110) + +**Category:** YAGNI candidate + +**Finding.** D31's evidence is the `han-linear` incident, which is a **channel-two** membership gap. D31 commits the +release to creation on all four targets. Per target: + +| Target | Evidence for creation | Verdict | +| --- | --- | --- | +| Channel-two version record | `han-linear` — documented, live | Passes | +| Channel-two listing membership | `han-linear` — same incident | Passes | +| Channel-one version record | none — D19 defines a plugin *by having* this file, so it cannot be missing | Fails | +| Channel-one listing entry | none — every entry present and resolving; D29 records the same | Fails | + +Channel-one-listing creation is the **most expensive** of the four — it is the one that must author a description +(F46) — and the **least likely to be needed**: per D21, the contributor guide tells contributors about channel one's +listing and no other target, so the target a forgetful contributor is most likely to have filled in is precisely the one +this capability creates. D29's "the behavior is free, it's the same comparison read the other way" covers *detection*, +not creation. + +**Evidence considered.** codebase — all eleven plugin directories carry a channel-one record; all twenty listing entries +across both channels resolve to a real directory (verified by `evidence-based-investigator` and re-checked directly). The +only live membership gap is `han-linear` on channel two. + +**Resolution.** Creation is scoped to the two channel-two targets, where the incident lives. A plugin missing from a +channel-one target is a gap creation cannot close. This is the YAGNI rule's "strictly simpler version satisfying the same +evidence": D31's headline promise still fires for the case it exists to serve, and the half with no evidence and the +highest content cost is removed. Keeping all four would have been the symmetry-and-completeness anti-pattern this spec +invokes to reject other items. Recorded as D36. + +**Resolved by:** user input (option: scope to the two channel-two targets), on evidence. + +**Raised in round:** R2 + +**Changed in plan:** Outcome, Primary flow (Step 3), Alternate flows and states, Edge cases and failure modes, +Coordinations + +**Changed in decision log:** D36 added; D31 corrected + +**Changed in tech-notes:** — + +### F48: D31's "no plugin for which the phrase is undefined" is false for the shape D19 deliberately admits + +**Agent:** `devops-engineer` (DOR-023 and DOR-R2-05), `junior-developer` (JD-108), `adversarial-validator` (V7), +`evidence-based-investigator` (F44 in its own numbering). Five independent arrivals from four specialists. + +**Category:** universal claim contradicted by a case the spec designed for + +**Finding.** D31 asserts: "'The version the release is publishing' is defined for every plugin… **There is no plugin for +which the phrase is undefined, so creation never has to guess.**" That holds only because the release reads the current +version from the plugin's channel-one record. **D19 explicitly rejected defining a plugin that way**, and kept the +contrary case alive on purpose: "Define a plugin as a directory with a channel-one manifest. Rejected: makes a +channel-two-only plugin invisible, which is the current bug mirrored." + +So the spec insists a channel-two-only plugin is a plugin the rule must see, and for that plugin the release has nothing +to read, no target version, and creation would have to guess exactly where D31 says it never does. Two decisions cannot +both be unqualified. + +**Evidence considered.** codebase — `.claude/skills/han-release/SKILL.md:157` reads `current` from +`{source}/.claude-plugin/plugin.json`; `:159-164` derives `baseline` from the same path. codebase — no such plugin exists +today; all eleven directories carry a channel-one record. So this is a false universal, not a live break. + +**Resolution.** D31's claim is narrowed to every plugin carrying a channel-one record, which is every plugin today, and a +plugin whose publishing version cannot be determined joins the gaps creation cannot close. Every agent that raised this +independently recommended against building a version-inference rule for a shape with zero members, and that +recommendation is taken: the gate already has the right verb, so the fix costs a clause and no machinery. Recorded on +D36 and as a correction to D31. + +**Resolved by:** evidence. + +**Raised in round:** R2 + +**Changed in plan:** Primary flow (Step 3), Alternate flows and states, Edge cases and failure modes + +**Changed in decision log:** D36 added; D31 corrected + +**Changed in tech-notes:** — + +### F49: Creation is the headline capability and nothing tells anyone it happened + +**Agent:** `devops-engineer` (DOR-R2-06), with `adversarial-validator` (V3) and `edge-case-explorer` (F-R2-2) arriving at +the same silence from the confirmation-gate side. + +**Category:** silent write reproducing the defect the work exists to close + +**Finding.** The spec is scrupulous that refusals are loud: the gate names every gap rather than the first, check +failures name the plugin and every target, and D30 exists so "malformed" is a category rather than a residue. Creation — +the capability F30 added and the Outcome now advertises — has no such commitment. The spec calls it "the ordinary path, +not the failure path", and the ordinary path is silent. + +It is silent in the release too: the version plan's vocabulary is bumped / unchanged / new, with no term for a created +record, and a release whose plan needs no confirmation prints nothing at all. So a release can create a plugin's +channel-two membership and version record, publish it, and never mention it. + +A process that writes to what Han publishes without saying so is a smaller instance of the defect being repaired. The +whole diagnosis is that something quietly stopped happening and nothing asked; a repair that quietly starts happening is +the same shape. + +The adjacent and larger worry, raised independently: creation makes a directory publicly installable with **no +sign-off**, a bigger act than the version bump that does get a mandatory confirmation. + +**Evidence considered.** codebase — `.claude/skills/han-release/SKILL.md:306-315` and `:220-226` print one line per +plugin in the bumped/unchanged/new vocabulary; `:219-221` prompts for nothing when no plugin needs confirmation. +Behavioral — D12 already establishes that naming the full set at once is what the release owes a maintainer. + +**Resolution.** A release reports what it created and corrected — which plugin, which targets, at what version — in the +report it already prints. Recorded as D39. The confirmation gate is deferred rather than rejected: reporting is the +strictly simpler thing that satisfies the same concern, and the case a confirmation guards against (a half-built plugin +merged and released before anyone noticed) has never happened. Recorded in Deferred (YAGNI) with a trigger. + +**Resolved by:** evidence. + +**Raised in round:** R2 + +**Changed in plan:** Primary flow (Step 3), Alternate flows and states, Edge cases and failure modes, User interactions, +Deferred (YAGNI) + +**Changed in decision log:** D39 added + +**Changed in tech-notes:** — + +### F50: The bundle's exception is stated in verbs that predate the release's ability to create + +**Agent:** `devops-engineer` (DOR-017) and `junior-developer` (JD-107), convergent. + +**Category:** unstated coordination between two decisions that never met + +**Finding.** D6 states the exception in exactly two verbs, both about looking: the bundle's absence from channel two is +never **flagged**, and it is never **asked to agree**. D31 gave the release a third verb — **create** — and carries no +bundle qualifier at all. Step 3 then repeated both verbs and added "**The exception stops there**", a sentence that +actively instructs a narrow reading, immediately before widening the rule back onto the bundle. + +The tell that the two decisions never met: D6's dependent-decisions list did not include D31, and D31's did not +include D6. + +An implementer applying D31 literally creates a channel-two listing entry and version record for the bundle. The gate +cannot catch it, because the exception told the rule not to look at the bundle on that channel — so the one plugin the +rule was instructed to ignore is the one the release would wrongly publish, silently and on every subsequent release. + +**Evidence considered.** codebase — `.agents/plugins/marketplace.json` contains no `han` entry and there is no +`han/.codex-plugin/`, so the bundle is permanently and correctly missing from two targets. codebase — `README.md:72-73`: +"Codex does not yet support meta-plugins like `han@han` (see openai/codex#23531,) and it resolves no dependencies". A +safe reading existed via D19's "belongs in" and one Edge-cases row, which is why this is a "say it" finding rather than a +"this is broken" one — but the cost of the implementer reading Step 3's unqualified sentence instead of that row is a +release publishing a meta-plugin to a channel that cannot install it. + +**Resolution.** The exception is restated against what the rule does on channel two rather than against an enumerated +verb list, so the next verb inherits it: never flagged, never asked to agree, **never created**. The Edge-cases row +carries the third verb too. D6 and D31 are added to each other's dependent-decision lists. + +**Resolved by:** evidence. + +**Raised in round:** R2 + +**Changed in plan:** Primary flow (Step 3), Edge cases and failure modes + +**Changed in decision log:** D6 extended; D31 corrected + +**Changed in tech-notes:** — + +### F51: The gate's approval anchor names a prompt that is off by default and misses the one that is not + +**Agent:** `devops-engineer` (DOR-020 and DOR-R2-02) and `junior-developer` (JD-105), convergent. + +**Category:** R1 resolution resting on a mis-read of the release + +**Finding.** F39's resolution anchored the gate to two boundaries: irreversibility, and "before it asks anyone to approve +publishing", on the rationale that "a gate that refuses after approval teaches people to distrust it". Both halves fail. + +**The prompt it names is opt-in and off by default.** So on the ordinary path nobody is asked to approve publishing at +all, and the boundary is vacuous — the late edge is unbounded again, which is the state F39 objected to. + +**The approval that always happens is earlier and on the wrong side of the writes.** The release's confirmation of its +version plan is its single mandatory gate, and it runs *before* the target writes. D24 forbids the gate from running +before those writes. So the gate is **structurally** after an operator authorization: F39 did not close the window, it +named the wrong door. The rationale is not merely unmet, it is unmeetable given D24's own early boundary. + +**Evidence considered.** codebase — `.claude/skills/han-release/SKILL.md:317-320` fires the publish prompt only "If +`pause_before_publish` is true"; `:65-67` defaults it to **false**. codebase — `:214-226` is titled "Confirm the plan +(**single mandatory gate**)" and runs at Step 3, before Step 4's writes. + +**Resolution.** The unmeetable clause is dropped. The gate keeps the two boundaries it can honor — it runs on the state +being released, after the writes, before anything irreversible — and the spec states the consequence honestly rather than +denying it: a release can refuse after the operator has confirmed a version plan, and what the gate owes them is that +nothing was published and every gap is named at once. The reviewer's alternative (split the gate so its membership half +answers before the plan confirmation) was not taken: it is a real change to D24's one-gate decision, and D24 rejected two +gates for reasons that still hold. + +**Resolved by:** evidence. + +**Raised in round:** R2 + +**Changed in plan:** Primary flow (Step 3), Alternate flows and states, User interactions + +**Changed in decision log:** D24 corrected + +**Changed in tech-notes:** — + +### F52: The branch-cut flow's backstop claim does not survive a release that repairs + +**Agent:** `devops-engineer` (DOR-022, with DOR-R2-07 scoping the same sentence from the step-3 side). + +**Category:** stated safety net that catches nothing + +**Finding.** The flow claimed: "A release is cut from a branch where a step has not landed. What stops it is the +release's own gate… the release gate is the only surface a missing step is caught on." Enumerate the instantiations +after D31 and nothing is left: + +- Step 3 not landed → the branch carries the old release, which has no gate at all. +- Step 4 not landed → the release now **repairs** the drift rather than stopping. +- Step 1 not landed → the release creates the missing records; the spec's own flow calls this "the ordinary path". +- Steps 5, 6, 7 → the gate does not inspect dependency declarations, documents, or its own existence. + +The sentence was true before F30, when the gate stopped on any version disagreement — precisely what a branch missing +step 4 has. D31 replaced the stop with a repair and the claim was not revisited. If a maintainer relies on it — cutting +from a feature branch believing the gate will refuse if something is missing — the release proceeds and publishes a state +nobody intended. + +**Evidence considered.** codebase — `.claude/skills/han-release/SKILL.md:76-78` explicitly permits cutting from a +non-default branch ("do not stop… surface the fact, do not block"), and `.github/workflows/ci.yml` triggers on +`pull_request`, so a branch with no pull request gets no pipeline run. There is no second surface. + +**Resolution.** The flow is restated to what is true: a release cut from a branch missing a step is mostly not caught, +and the gate's job is the gaps a release cannot close rather than the steps of this plan. The restatement also names the +gain the old claim obscured — after D31, cutting from a branch missing step 1 or step 4 is *safe* rather than merely +caught, because the release repairs it. Same honest-downgrade shape D32 already applied to the check. + +**Resolved by:** evidence. + +**Raised in round:** R2 + +**Changed in plan:** Alternate flows and states + +**Changed in decision log:** D24 corrected + +**Changed in tech-notes:** — + +### F54: The gate-stop recovery does not account for the release's own writes, and the naive move skips the mandatory confirmation + +**Agent:** `devops-engineer` (DOR-019 and DOR-R2-04) and `junior-developer` (JD-104), convergent. + +**Category:** recovery path that does not survive F30's resolution + +**Finding.** F38's resolution gave the gate stop its own recovery: "Recovery is **not** discarding local changes — the +gap is in the repository and not in the release's uncommitted work. The gap must be corrected and committed." + +The first clause is right about the gap and wrong about the tree. At gate-stop time the tree always holds the release's +own work, because D24 places the gate after all four target writes. The two stated recoveries therefore overlap rather +than compose, and the spec never says what becomes of those writes — though they must go somewhere, since the release +refuses to start dirty. + +Both available moves are bad and the spec picks neither: + +- **Commit everything.** The gap fix lands together with the release's half-applied version bumps. On the re-run the + release sees those versions as already ahead of their baseline, takes the ahead path, needs no confirmation, and its + **single mandatory gate is silently skipped**. Recovering from a gate stop converts a confirmed release into an + unprompted one — a step of the release deciding something a person was supposed to decide, which is the class of defect + this work exists to end. +- **Discard, then fix.** Actually correct, but the spec's sentence reads as prohibiting it. And a careless discard is not + enough: creation writes **untracked** files, which survive a discard aimed at modified ones and keep the tree dirty — + a second loop, this one caused by creation. + +**Evidence considered.** codebase — `.claude/skills/han-release/SKILL.md:72-74` hard-stops on a dirty tree and counts +untracked files (`git status --porcelain` at `:37`). `:203-213` takes the ahead path when `current` is strictly ahead of +`baseline` ("**No confirmation for this plugin**"); `:219-221` "If no plugin needs confirmation… **Do not prompt**". + +**Resolution.** The recovery is stated as two acts in order: the release's own local work is discarded first, including +what it created, then the gap is corrected and committed on its own, then the release re-runs and plans from scratch. +The spec states the reason rather than only the sequence, because the reason is the load-bearing part. This does not +reopen D28. A separate point from `edge-case-explorer` (F-R2-5) is folded in: the correction must reach the branch +releases are cut from, since a gap fixed only on a throwaway release branch is still there for everyone else. + +**Resolved by:** evidence. + +**Raised in round:** R2 + +**Changed in plan:** Alternate flows and states + +**Changed in decision log:** D34 completed + +**Changed in tech-notes:** — + +### F55: D33 states a sentence rule and applies a proximity rule + +**Agent:** `adversarial-validator` (V3) + +**Category:** scoping rule contradicting its own worked example + +**Finding.** D33 claims its test is "whether the sentence is being rewritten regardless", explicitly not proximity. Its +one worked example applies proximity. The orientation document's three relevant paragraphs are separate: the paragraph +naming the four plugins that depend on the core plugin (falsified by step 5), the paragraph describing the bundle's own +dependencies (already false, **not** falsified), and a third paragraph about the opt-in plugins (falsified). The +already-false claim sits in its own paragraph, about a different subject, between two that are genuinely being rewritten +— and the spec justified pulling it in with "the surrounding sentences are being rewritten", which is adjacency, the +"sits nearby" reasoning D7 and the Out-of-scope section name and reject. + +Left unbounded, a future implementer could extend "passage" to "anything in a document step 6 touches", which is the +open-ended audit D33's own rejected alternatives say it refuses to become. + +**Evidence considered.** codebase — verified directly during consolidation: the three paragraphs are distinct, and the +bundle paragraph's dependency list omits `han-communication` while the real manifest includes it. + +**Resolution.** The boundary is stated: **a passage is the paragraph** — narrower than the document-wide audit, wider +than a sentence, and the unit a person actually rewrites. Under that rule the worked example does **not** qualify, and +saying so is the point. It is corrected anyway by a route it does qualify under: that paragraph is one of the document's +dependency enumerations, and step 6 is already rewriting those to drop their counts and name the manifests as the record +(D26). Applying that remedy means checking the enumeration against the manifests, and one that disagrees is corrected by +the act of applying it — not by being nearby. + +**Resolved by:** evidence. + +**Raised in round:** R2 + +**Changed in plan:** Primary flow (Step 6) + +**Changed in decision log:** D33 bounded + +**Changed in tech-notes:** — + +### F56: "The window does not exist" was stated as fact and nothing operationalized "one unit" + +**Agent:** `adversarial-validator` (V5) + +**Category:** unstated assumption inside a structural claim + +**Finding.** D18 rejected "add a precondition that no release is cut in the window" because "it relies on a maintainer +honoring a promise with nothing enforcing it", and F29 claimed the structural fix instead: "The two steps land together, +and the window does not exist." Nothing in the spec or the decision log said what "land together" means. If it is not +atomic — one commit, one change — the fix is still a promise, which is the thing D18 says it rejected. Nothing in this +repository enforces atomicity: T2 already established nothing blocks anything here. + +**Evidence considered.** provided/codebase — no statement of single-commit or single-pull-request delivery exists +anywhere in the spec or decision log. T2's live configuration confirms nothing enforces it. + +**Resolution.** Largely absorbed: F44 removed the 3+4 unit entirely, so the claim it was attached to is gone. What +survives applies to steps 5 and 6, which remain a genuine unit. The Coordinations bullet now says they ship in a single +change rather than in two that follow each other closely, and states plainly that this is a commitment about how the work +is shipped rather than a property of the work — because nothing here enforces it. + +**Resolved by:** evidence. + +**Raised in round:** R2 + +**Changed in plan:** Primary flow (binding constraints), Coordinations + +**Changed in decision log:** D38 added + +**Changed in tech-notes:** — + +### F58: A half-finished removal would be silently undone by a repairing release + +**Agent:** `edge-case-explorer` (F-R2-1) + +**Category:** silent write reproducing the defect the work exists to close + +**Finding.** The spec never addresses plugin **removal**, and D31 makes its absence consequential. D19 defines a plugin +inclusively — a directory the suite ships as an installable unit — deliberately rejecting "has a channel-one manifest" +so a channel-two-only plugin stays visible. So a directory that survives a half-finished removal is still a plugin, and +per D31 its now-missing channel-two records are simply "missing from a target" and get **created back** — silently +republishing what a maintainer was retiring. D29 covers only the mirror case (a listing entry pointing at nothing). + +Removal is not hypothetical: the release process already versions "a child was removed from the suite" as a major bump. + +**Evidence considered.** codebase — the release skill's removal rule confirms removal is an anticipated event. +Behavioral — D19's inclusive definition plus D31's create-path compose into the resurrection without either decision +saying so. + +**Resolution.** The rule refuses the ambiguity rather than guessing at intent. "Never published here" and "published +here until yesterday" are the same state on disk, and inferring the difference would mean consulting history to guess a +person's intention on a case with no live instance. So: a plugin the repository still carries is a plugin the rule +expects in every target it belongs in — a directory that remains is a plugin, and removing a plugin means removing the +directory. That gives the maintainer one thing to get right instead of four, which is the simplification the rest of the +work is built on. Recorded as D40. + +**Resolved by:** evidence. + +**Raised in round:** R2 + +**Changed in plan:** Alternate flows and states, Edge cases and failure modes + +**Changed in decision log:** D40 added + +**Changed in tech-notes:** — + +### F59: A corrupt storefront listing would route a whole channel into the create-path + +**Agent:** `edge-case-explorer` (F-R2-3) + +**Category:** boundary of a rule written for a different artifact shape + +**Finding.** D35's rule ("a record that cannot be read is surfaced and blocking, never a plugin quietly dropped") is +written and evidenced entirely in terms of a **per-plugin record**. The spec's own glossary distinguishes the two shapes: +a storefront listing is "one per channel", a single shared file covering every plugin. If that file fails to parse — a +trigger D35 itself names, a hand-editing slip during steps 1 or 4 — every plugin in the channel would appear +simultaneously missing from that target, which post-D31 means "missing → create". A corrupted listing could therefore +trigger a mass-creation pass that regenerates the file rather than surfacing one blocking failure. D35's own rejected +alternative ("treat an unreadable record as a missing one — rejected: it produces the right stop for the wrong reason") +was never stated for the shared shape. + +**Evidence considered.** codebase — D35's own cited evidence (`.claude/skills/han-release/SKILL.md:37,39`) reads the +whole listing in one call covering every plugin, so the mismatch between the rule's wording and its evidence is in the +decision itself. + +**Resolution.** An unreadable storefront listing is surfaced and blocking for that whole channel, never read as mass +absence. Added to Edge cases and recorded as an extension to D35. This is not a symmetry argument — the failure is silent +and would regenerate a published file, which is the loudest possible version of the defect the work exists to end. + +**Resolved by:** evidence. + +**Raised in round:** R2 + +**Changed in plan:** Edge cases and failure modes + +**Changed in decision log:** D35 extended + +**Changed in tech-notes:** — + +### F61: "Stays green" and "not blocked by the check" outlive F28's correction + +**Agent:** `devops-engineer` (DOR-021), `junior-developer` (JD-109), `adversarial-validator` (V6). Three independent +greps for surviving blocking language found exactly these two. + +**Category:** overclaim outliving its correction + +**Finding.** Two sentences still carry the pre-F28 assumption that a pull-request check blocks: + +- Step 7: "the check is green on the day it arrives **and stays green**." Only true of a check that blocks. The spec + contradicts it directly two sections away ("If they merge past it — **which they can** — the next release creates the + missing records itself"), and it is cited to D1, whose corrected rationale is precisely that on an advisory surface the + failure mode is a red check people scroll past. It sits two paragraphs below the sentence F28 deleted for the same + reason. +- Outcome: "so a contributor following the contributor guide is **not blocked by the check**" — implies a blocking + capability that merely fails to trigger here, when T2 established the check cannot block anyone in any case. + +**Evidence considered.** T2 (live platform configuration, re-verified in R2 by `evidence-based-investigator` C3: the +default branch returns not-protected, zero rules apply, the sole ruleset is disabled with no required-status-check rule). + +**Resolution.** "Stays green" is replaced with what is actually true and makes D1's restated rationale visible where the +check lands: green on arrival, red on the pull request that introduces a gap, mergeable past, and the default branch then +carries a red check until the next release repairs it. "Not blocked by" becomes "not flagged by". + +**Resolved by:** evidence. + +**Raised in round:** R2 + +**Changed in plan:** Outcome, Primary flow (Step 7) + +**Changed in tech-notes:** — + +### F63: The examine-first guarantee is stated absolutely and evidenced only for foreign annotations + +**Agent:** `edge-case-explorer` (F-R2-4) + +**Category:** boundary between adjacent categories + +**Finding.** Step 2 states generally: "A work item whose annotation the publisher does not recognize is surfaced as a +format error, and **the run stops before creating anything at all**". D3, its citation, is scoped and evidenced +specifically for the **foreign-annotation** case — its rationale is about avoiding duplicate publication to another +tracker. D30 separately broadens "accounted for" to every malformed heading and names the plain case as "the most likely +malformation in practice". The spec never says whether the examine-first guarantee — justified only for foreign +annotations — extends to the plain malformed case, though its own wording ("at all", "before the first item is +published") reads as uniform. + +**Evidence considered.** codebase — the publisher's repair pass carries "Malformed heading" as a distinct category, +separate from the foreign-annotation gate, so the two are already different things in the code. D30's own text names the +hyphen-versus-dash case as the likely one. + +**Resolution.** Stated explicitly, with the reason rather than by symmetry: the publisher cannot tell the two apart until +it has looked — a heading it fails to parse might be another tracker's annotation in a shape it does not know, or a +hand-edited line with the wrong dash. They need the same answer because the cheaper answer (publish what you understood, +then complain) is the one that creates issues in a file that may already have been published elsewhere. + +**Resolved by:** evidence. + +**Raised in round:** R2 + +**Changed in plan:** Primary flow (Step 2) + +**Changed in tech-notes:** — + +### F64: "Nine gaps" is stated as flat fact three times and is conditional + +**Agent:** `junior-developer` (JD-106) + +**Category:** unverified count driving spec prose (the class F32 established) + +**Finding.** Step 1 creates the Linear plugin's record at its channel-one version (D22), so **it arrives agreeing**. At +the moment step 3 lands the count is **eight** disagreements, not nine, and two plugins agree rather than one. Yet Step 3 +said "every plugin but **one** has version records that disagree" and "a gate that is live against **nine** gaps", and +Step 4 said "This step closes **nine** gaps, not eight". The ninth exists only if a release is cut between step 1 and +step 3 — Step 1's own F34 caveat says so — so the spec asserted as certain, in three places, something its own Step 1 +paragraph makes conditional. + +**Evidence considered.** codebase — verified independently by `evidence-based-investigator` (C1) and by direct read of +every manifest on both channels: eight plugins disagree, `han-communication` agrees, `han-linear` has no channel-two +record, the bundle has none by design. + +**Resolution.** Stated as eight, with the ninth named as a contingency rather than a count. The point the number served — +the gate is live against a red repository — is itself removed by F44, so what remains is Step 4's scope. Same reasoning +F32 used: the qualitative point does not depend on the number, so the number should be right. + +**Resolved by:** evidence. + +**Raised in round:** R2 + +**Changed in plan:** Primary flow (Step 3, Step 4) + +**Changed in tech-notes:** — + +## Rejected findings + +- **F65 (rejected): D18's execution-order numeral list is not inconsistent with its gloss.** `edge-case-explorer` read + "The steps execute as 1, 2, 6, 3, 4, 5, 7" as spec numbering and concluded the following gloss described 1–7 in order. + The list is stated in **the source plan's** numbering, as the sentence says, and the steps' own `_Source position:_` + annotations confirm it: spec steps 1–7 carry source positions 1, 2, 6, 3, 4, 5, 7 respectively. The gloss matches the + list exactly. No change. +- **F66 (rejected): `han-atlassian`'s `han-communication` declaration is not a fourth untrue dependency.** Raised by one + `adversarial-validator` pass as a possible fourth deletion by the same grep test that condemned the other three, and + refuted by a second `adversarial-validator` pass and by `evidence-based-investigator` (C4, C5) independently. All three + agree on the facts — `han-atlassian` never names `han-communication`, and its wrapped skills invoke it one layer down — + and the disagreement was on the verdict. Resolved by evidence rather than by preferring an agent: `README.md:84-85` + documents the declaration's purpose directly ("Because Codex resolves no dependencies, install `han-communication` + alongside `han-atlassian` (its wrapped prose-producing skills source the shared readability standard from it)"), and + `docs/concepts.md:225-227` documents the identical transitive-necessity pattern for the `han-planning` and `han-coding` + edges. The declaration is real and deliberate. The gap was that D8 and D25 never stated the test that distinguishes it + from `han-linear`'s — recorded as F62 rather than as a deletion. Step 5 remains three. + ## Minor edits - F42: Open item 4 is parked with no dependent step, which is the same shape F22 used to remove the old open item 3 — @@ -529,3 +1197,22 @@ cases. mirror of the comparison D19 already requires) and is kept; the evidence wording is corrected to say so rather than lean on a precedent that demonstrates the opposite. — `adversarial-validator` (V4) — Edge cases (D29 evidence wording; behavior unchanged) +- F53: F29's resolution named four spec sections and no decision, so the steps-3+4 unit was written into the spec while + D18 continued to say "precedes" — the spec cited D18 for a claim D18 never made. F44 vindicated D18, so the fix is a + note recording that the decision was right and the citing document was wrong, rather than an edit. — `junior-developer` + (JD-102) — D18 (annotated; no outcome change) +- F57: Creation writes untracked files, which survive a discard aimed at modified files and keep the tree dirty — a + second recovery loop caused by creation rather than by the gap. — `junior-developer` (JD-104) — Alternate flows + (folded into F54's resolution and D34) +- F60: D35's non-circularity argument asserts that plugin identity survives an unreadable manifest because "D19 defines a + plugin by the manifest's presence, not its contents" — a filesystem-based identity, while the release's existing + convention matches records by the name **inside** them, and D19 explicitly defers detection to the implementation plan. + The behavioral commitment survives either way (the failure can be named by the directory it was found in), so the + argument is softened rather than the outcome changed. — `adversarial-validator` (V4) — D35 (rationale; behavior + unchanged) +- F62: D8 verifies `han-atlassian`'s `han-core` edge "by use, not by self-description" and calls the manifest true on the + strength of it, while three other declared edges went uncited — and one of them (`han-communication`) would fail the + very test D8 states it is applying. All four were verified in R2 and all four are real, but the distinction that makes + the fourth real was never written down: a declaration whose *wrapped* skills exercise it is genuine; a declaration + whose plugin is not granted the means to call anything is not. — `evidence-based-investigator` (F46), + `adversarial-validator` (V1) — D8 (evidence extended; conclusion unchanged) diff --git a/docs/plans/han-publishing-cleanup/artifacts/review-iteration-history.md b/docs/plans/han-publishing-cleanup/artifacts/review-iteration-history.md index a368dabd..93ac6d9a 100644 --- a/docs/plans/han-publishing-cleanup/artifacts/review-iteration-history.md +++ b/docs/plans/han-publishing-cleanup/artifacts/review-iteration-history.md @@ -81,3 +81,115 @@ rather than that the earlier work was weak. facts about systems outside its own prose — what the release skill can do, what the platform enforces, how many releases had run. R2 should verify that R1's own resolutions hold, and specifically stress the newly added D31, whose creation capability is the largest untested commitment in the spec and was chosen against the recommendation. + +## R2 + +**Mode:** team + +**Spec-aware mode:** engaged. Roster unchanged from R1 and still excludes the mechanic-level specialists per the +spec-stage rules; every agent received the behavioral-level brief, the YAGNI brief, and the evidence brief, plus R1's +findings so nothing already resolved was re-raised. + +**Size:** large — unchanged. Round cap 3, team cap 5. This is round 2. + +**Specialists engaged:** `junior-developer`, `adversarial-validator`, `evidence-based-investigator`, `devops-engineer`, +`edge-case-explorer`. All five returned output. Two of them (`devops-engineer`, `adversarial-validator`) additionally +returned a second, independently-run pass, which is treated as corroboration rather than as extra findings — where the +two passes agreed, the finding is recorded as convergent; where they disagreed, the disagreement is recorded and +resolved on evidence (see F66). + +**Findings raised:** F44, F45, F46, F47, F48, F49, F50, F51, F52, F54, F55, F56, F58, F59, F61, F63, F64 (major); F53, +F57, F60, F62 (minor); F65, F66 (raised and rejected). Numbering continues from R1, which reached F43. + +**Convergence.** The round's findings cluster almost entirely on **D31** — the creation capability the user chose in R1 +against the reviewer's recommendation, and the one R1's own next-step note flagged as the largest untested commitment. +Five specialists independently found D31's "there is no plugin for which the phrase is undefined" false for the exact +plugin shape D19 spends a rejected alternative keeping alive (F48). Three found that the bundle's exception is written +in verbs that predate creation (F50). Three found the gate's approval anchor names a prompt that is off by default +(F51). Three found the gate-stop recovery does not account for the release's own writes (F54). Three separate greps for +surviving blocking language found the same two sentences and no others (F61). Convergence at this density on one +decision is the signal that D31 was under-specified rather than wrong. + +**The round's central finding is a collision between two R1 resolutions.** F29 (steps 3+4 become one unit) and F30 (the +release creates rather than detects) were decided in the same round and never checked against each other. F30 gave the +release the ability to repair; F29's entire justification was a release freeze that only a non-repairing gate produces. +The junior-developer reached it from the spec's own three enumerations of "gaps creation cannot close", all of which +omit version disagreement while a sentence ten lines away claims the gate is live against nine of them; the +devops-engineer reached it from the gate's placement, observing that every gap the gate can still fire on is +pre-existing repository state rather than anything the release wrote. Recorded as F44. + +**Disagreement resolved.** The two `adversarial-validator` passes split on whether `han-atlassian`'s `han-communication` +declaration is a **fourth** untrue dependency. Both established the same facts — `han-atlassian` never names it, and its +wrapped skills invoke it one layer down — and differed on the verdict. Resolved on evidence rather than by preferring an +agent: `README.md:84-85` documents the declaration's purpose outright, and `evidence-based-investigator` (C4, C5) +independently confirmed the pattern is applied consistently across the suite. The declaration is real. What was missing +was the stated test distinguishing it from `han-linear`'s, which is recorded as F62 rather than as a deletion. Step 5 +remains three. Recorded as F66 (rejected). + +**Surfaced to the user.** Three findings required judgment only the plan's author could make and were surfaced with +impact, trade-offs, and a recommendation: + +- F44 — does a release overwrite a stale version record for a plugin it did not bump? **User chose yes**, and to restate + the unit. Steps 3 and 4 revert to an ordering, which is what D18 always said. +- F46 — what does a created record contain, given that channel two's record carries authored prose and its listing entry + carries an installation policy? **User chose to narrow D31** (the recommendation): the release creates what it can + derive and stops at what must be authored. +- F47 — creation is committed on four targets on evidence that exists for two. **User chose to scope creation to the two + channel-two targets** (the recommendation), which is the YAGNI rule's strictly-simpler-version path. + +**Verified and confirmed unchanged.** `evidence-based-investigator` re-verified R1's foundational corrections and all +held: the eight-plus-one drift table is exact (C1); F32's count of eleven releases is exact, re-derived by an +independent `git merge-base --is-ancestor` sweep over every tag and corroborated by a second agent using a different +method (C2); T2's live platform configuration is unchanged (C3); F31's set of exactly three untrue declarations is +closed, checked against every declared edge in every plugin rather than the three suspected (C4). D8's repoint target +holds (C5, V6). This matters: R1's volume raised the question of whether its own resolutions were sound, and the answer +is that its facts were right and its reasoning about the interaction between two of them was not. + +**Changed in plan:** Channels and targets; Outcome; Primary flow (binding constraints, Step 1, Step 2, Step 3, Step 4, +Step 6, Step 7); Alternate flows and states; Edge cases and failure modes; Coordinations; User interactions; Deferred +(YAGNI); Summary; Review History. + +**Changed in decision log:** D36, D37, D38, D39, D40 added. Corrections and extensions: + +- **D6** — its exception was stated in two verbs, both about looking. D31 added a third the exception never acquired, so + a repairing release would publish the bundle to the channel that cannot install it. Restated against what the rule + does on that channel rather than against a verb list. +- **D8** — its evidence verified one of four declared edges and called the manifest true on that basis. All four + verified; the stated test that distinguishes a wrapped-skill dependency from a decorative one is now recorded. +- **D18** — annotated, not edited. It was right: R1's unit claim was never written into it, and F44 restored the spec to + what it always said. +- **D19** — a stale count ("the ten real plugin directories", now eleven) dropped rather than corrected, per this + repository's convention that indexes are verified complete rather than counted. +- **D24** — two corrections. Its R1 approval anchor is unmeetable, and its claim to settle what stops a branch-cut + release no longer holds against a release that repairs. +- **D31** — four corrections: creation scoped to channel two, "membership and nothing more" falsified, the + undefined-version universal narrowed, and its scoping by D19 and D6 made explicit. +- **D33** — its stated rule (sentence) and its applied rule (proximity) disagreed. Bounded to the paragraph. +- **D34** — completed. The gate stop's recovery never accounted for the release's own writes, and the obvious move + disarms the release's only mandatory confirmation. +- **D35** — extended to the shared listing shape, and its non-circularity argument softened. + +**Changed in tech-notes:** none. No finding this round required a new load-bearing mechanic. T1 remains unverified and +owned by Open item 1; T2 was re-verified and is unchanged. + +**YAGNI.** Two new deferrals, both with triggers: distinguishing an empty work-items file from the wrong file (raised by +`edge-case-explorer`, deferred because nobody has done it and the run already reports two zeroes), and confirming a +plugin's first publication before a release makes it installable (raised by three agents, deferred because D39's +reporting is the strictly simpler thing that satisfies the same concern). One first-class YAGNI finding was raised and +resolved by narrowing rather than deferring (F47). The round also declined two tempting additions on YAGNI grounds: a +version-inference rule for a plugin shape with zero members (F48), and an apparent-removal detector that would infer +intent from absence (F58). + +**Stability assessment:** not stable, but the trajectory changed. The round produced 17 major findings, three of which +required user judgment and one of which (F44) reversed an R1 resolution. That is still far past the stop rule. But the +character is different from R1: R1 falsified claims about the world (what the platform enforces, what the release can +do, how many releases ran), and R2 falsified almost nothing about the world — every foundational fact it re-checked held +up. What R2 found was that one decision (D31) was granted late in R1 and never propagated into the six neighbouring +decisions it changed. That is a containable defect, and it is now contained. + +**Next step:** run R3, and scope it narrowly. R2's own resolutions added five decisions and touched eight, all of them +in the same neighbourhood, and the lesson of F44 is precisely that decisions made in one round and not checked against +each other are where the next round's findings live. R3 should verify that D36 through D40 do not collide with each +other or with what they corrected — particularly D36's authored-presence gate stop against D31's "ordinary path" framing, +and D37's commit commitment against D34's recovery order. The specification's factual foundation is now well-tested and +should not need re-litigating. diff --git a/docs/plans/han-publishing-cleanup/feature-specification.md b/docs/plans/han-publishing-cleanup/feature-specification.md index 4d4678ce..2a2e386c 100644 --- a/docs/plans/han-publishing-cleanup/feature-specification.md +++ b/docs/plans/han-publishing-cleanup/feature-specification.md @@ -712,8 +712,8 @@ The people who experience this feature are maintainers and contributors at a ter - **Outcome.** Han publishes completely and honestly to both channels, and no release can quietly stop it being so. - **Actors.** Han maintainers, contributors, and channel-two installers. -- **Scope.** Seven steps. The check is last; the release repair and the version correction are one unit; the - declaration deletion and document correction are one unit; the work-items fix is independent. +- **Scope.** Seven steps. The check is last; the release repair precedes the version correction; the declaration + deletion and document correction are one unit shipped in a single change; the work-items fix is independent. - **Decisions.** See [decision-log.md](artifacts/decision-log.md). - **Technical notes.** See [feature-technical-notes.md](artifacts/feature-technical-notes.md). - **Sub-agents.** Planning: junior-developer, devops-engineer, edge-case-explorer, information-architect — see @@ -723,12 +723,19 @@ The people who experience this feature are maintainers and contributors at a ter - **Key adjustments from planning.** The execution order was changed to close a window that would have undone step 4; the gate was given a placement; the check and release were given one bearer instead of two; the document survey was restated as a rule and grew by five locations; and a factual error about the number of version records was corrected. -- **Key adjustments from review.** The headline enforcement claim was found to have no bearer and was restated per - surface — only the release refuses, a pull request merely reports. Steps 3 and 4 became one unit, closing a release - freeze the reorder had opened. The release gained the ability to create a missing record rather than only report it. - A third untrue dependency declaration was found. The bundle's version exemption was split, and the release count was - corrected from "roughly twenty" to eleven. -- **Deferred under YAGNI.** 4. +- **Key adjustments from the first review round.** The headline enforcement claim was found to have no bearer and was + restated per surface — only the release refuses, a pull request merely reports. The release gained the ability to + create a missing record rather than only report it. A third untrue dependency declaration was found. The bundle's + version exemption was split, and the release count was corrected from "roughly twenty" to eleven. +- **Key adjustments from the second review round.** Almost all of them trace to one place: the release's new ability to + repair was granted late in the first round and never carried into the decisions it changed. The freeze that made steps + 3 and 4 one unit does not happen, because a repairing release closes the disagreements rather than refusing over them — + so they are an ordering again, which is what the decision log always said. Creation was scoped to the two channel-two + targets where the evidence is, and stopped at content a person must author, because a storefront record turned out to + carry authored prose rather than just a version. The release now commits every target it writes, without which the tag + would have named a state the gate never saw. The bundle's exception gained the verb "create". The gate's promise to + refuse before anyone approves was dropped as unmeetable. +- **Deferred under YAGNI.** 6. - **Open items.** 4, of which two are unverified external behaviors, one is a settled fact carrying an unmade decision, and one is a real question the team owes itself. @@ -736,9 +743,9 @@ The people who experience this feature are maintainers and contributors at a ter - **Review mode:** team. - **Spec-aware mode:** engaged. -- **Rounds completed:** 1 of a 3-round cap — see - [artifacts/review-iteration-history.md](artifacts/review-iteration-history.md). **Not stable; another round is - recommended.** +- **Rounds completed:** 2 of a 3-round cap — see + [artifacts/review-iteration-history.md](artifacts/review-iteration-history.md). **Not stable; a third round is + recommended, scoped to the decisions round 2 added.** - **Team composition:** - `junior-developer` — required; reframes the spec in plain terms and surfaces hidden assumptions and standards conflicts. @@ -749,27 +756,47 @@ The people who experience this feature are maintainers and contributors at a ter - `devops-engineer` — the release gate, the enforcement surface, and the rollout ordering are the spec's spine. - `edge-case-explorer` — the failure-mode table and the stop-before-create gate carry the spec's silent-failure commitments. -- **Findings raised:** 16 — see [artifacts/review-findings.md](artifacts/review-findings.md). 14 major, 2 minor. By - resolution source: 4 by user input on surfaced trade-offs, 12 by evidence. -- **YAGNI candidates:** 0 new items raised as `Category: YAGNI candidate`. The planning pass's YAGNI work held up under - challenge — nothing smuggled back into scope, and all four deferrals carry live triggers. One deferral's trigger was - found to have already fired and was restated rather than silently left standing ([F31](artifacts/review-findings.md#f31-han-linear-is-a-third-untrue-dependency-declaration-and-the-specs-own-deferral-trigger-has-already-fired)), +- **Findings raised:** 37 across both rounds — see [artifacts/review-findings.md](artifacts/review-findings.md). Round 1: + 14 major, 2 minor. Round 2: 17 major, 4 minor, plus 2 raised and rejected. By resolution source: 7 by user input on + surfaced trade-offs, 30 by evidence. +- **YAGNI candidates:** 1 raised as `Category: YAGNI candidate`, in round 2, and resolved by the + replace-with-simpler-version path: creation was committed on all four targets on evidence that exists for two, and is + now scoped to the two where the incident lives + ([F47](artifacts/review-findings.md#f47-creation-is-committed-on-all-four-targets-on-evidence-that-exists-for-two)). + Round 1 raised none, and the planning pass's YAGNI work has held up across both rounds — nothing smuggled back into + scope. One deferral's trigger was found to have already fired and was restated rather than silently left standing + ([F31](artifacts/review-findings.md#f31-han-linear-is-a-third-untrue-dependency-declaration-and-the-specs-own-deferral-trigger-has-already-fired)), and one kept behavior had its justification replaced after the original evidence was withdrawn - ([F43](artifacts/review-findings.md#minor-edits)). + ([F43](artifacts/review-findings.md#minor-edits)). Round 2 added two deferrals with triggers and declined two tempting + additions on YAGNI grounds — a version-inference rule for a plugin shape with no members + ([F48](artifacts/review-findings.md#f48-d31s-no-plugin-for-which-the-phrase-is-undefined-is-false-for-the-shape-d19-deliberately-admits)) + and an apparent-removal detector that would infer intent from absence + ([F58](artifacts/review-findings.md#f58-a-half-finished-removal-would-be-silently-undone-by-a-repairing-release)). - **Assumptions challenged across all passes:** that a red pull-request check blocks a merge (false — nothing here makes it blocking); that the rule only starts refusing at step 7 (false — it refuses from step 3, because the release runs it); that the repaired release could fix what it found (false — it could only report); that two plugins carried untrue dependency declarations (false — three do); that the bundle has nothing to disagree with (false — it publishes a - version in two records); and that the drift spanned roughly twenty releases (false — eleven). -- **Consolidations made:** steps 3 and 4 became one unit, joining steps 5 and 6, so the binding constraints now name two - units rather than one unit and one ordering. The check and the release keep one bearer; what changed is that the spec - now states what that bearer can enforce on each surface. -- **Ambiguities resolved, and how:** what version a created record carries (D31, by extension from what a release - already writes); which documents state the rule versus keep their enumeration (D26, scoped to the sentence shape it - was reasoned from); whether an already-false neighbor is corrected (D33, stated once as a rule that generalizes what - D9 and D27 were already doing); and what happens to a record that cannot be read at all (D35, previously undefined). -- **Technical notes added/edited:** 1 added — see - [artifacts/feature-technical-notes.md](artifacts/feature-technical-notes.md). + version in two records); that the drift spanned roughly twenty releases (false — eleven); that the gate would freeze + releases between steps 3 and 4 (false — a repairing release closes the disagreements it would have stopped on); that a + version record states a version (false — it carries the plugin's authored storefront presence, and a channel-two + listing entry carries the policy deciding installability); that the release publishes what it writes (false — its + commit reaches two of four targets); and that a plugin's publishing version is always defined (false for the + channel-two-only shape the spec deliberately admits). +- **Consolidations made:** none survive. Round 1 merged steps 3 and 4 into one unit; round 2 found the freeze that + justified it does not occur and reverted them to the ordering the decision log had recorded all along. Steps 5 and 6 + remain the one genuine unit, now stated as a single change rather than as two that follow closely. The check and the + release keep one bearer, and the spec now states both what that bearer can enforce on each surface and why the two + surfaces answer differently. +- **Ambiguities resolved, and how:** what version a created record carries (D31, narrowed in round 2 to name the + authoritative source and to stop claiming totality); what a created record actually contains (D36, after the record + turned out to carry authored prose); which documents state the rule versus keep their enumeration (D26, scoped to the + sentence shape it was reasoned from); whether an already-false neighbor is corrected (D33, bounded to the paragraph + after its stated rule and its worked example were found to disagree); what happens to a record that cannot be read + (D35, extended to the shared listing shape); what the release does with what it wrote (D37); what a gate stop leaves + behind (D34, completed); and what a half-finished removal means (D40). +- **Technical notes added/edited:** 1 added, in round 1 — see + [artifacts/feature-technical-notes.md](artifacts/feature-technical-notes.md). Round 2 added none and required none; + T2 was re-verified against the live platform and is unchanged. - **Open items remaining:** 4. None blocks implementation. Items 1 and 2 are external client behaviors that shape how outcomes are worded rather than whether steps work ([F35](artifacts/review-findings.md#f35-step-1s-install-succeeds-promise-is-unhedged-against-open-item-2-while-the-parallel-claim-is-hedged)). Item 3 is now a settled fact carrying an unmade decision, not an unknown From b8090058eee981e72698eecb792226ca0258fd46 Mon Sep 17 00:00:00 2001 From: River Lynn Bailey <river.bailey@testdouble.com> Date: Thu, 16 Jul 2026 14:29:15 -0600 Subject: [PATCH 05/22] docs(plans): apply R3 review findings; review reaches its round cap All three R3 specialists independently found that D36 contradicted itself: it committed creation to two channel-two targets while establishing, in its own rationale, that one of them IS the authored presence the other half refuses to invent. A record that is the presence cannot be created. That is verbatim the mistake R2 caught R1 making, in the decision R2 wrote to fix it. Nine of R3's ten major findings are its blast radius. Creation now reaches channel two's listing entry alone. The Linear plugin is closed by a person at step 1, as step 1 always said and step 3 denied. Step 1 becomes a binding constraint, and a new plugin merged past the check stops the next release rather than costing lateness. Adds D41; corrects D24, D34, D36, D39, D40. Records F67-F81 and the R3 round. Review stops at its 3-round cap, not stable. --- .../artifacts/decision-log.md | 163 +++++-- .../artifacts/review-findings.md | 408 ++++++++++++++++++ .../artifacts/review-iteration-history.md | 91 ++++ .../feature-specification.md | 293 +++++++++---- 4 files changed, 835 insertions(+), 120 deletions(-) diff --git a/docs/plans/han-publishing-cleanup/artifacts/decision-log.md b/docs/plans/han-publishing-cleanup/artifacts/decision-log.md index a9642413..54aab34e 100644 --- a/docs/plans/han-publishing-cleanup/artifacts/decision-log.md +++ b/docs/plans/han-publishing-cleanup/artifacts/decision-log.md @@ -859,8 +859,15 @@ to reverse. Between the writes and the commit is the only window where the gate harmlessly. This also makes partial failure a non-event: every write precedes anything irreversible, so a failure mid-write leaves -local modifications and nothing published. Recovery is discarding them. No compensation or rollback machinery is needed, -and specifying any would be building for an incident that cannot happen. +the release's local work and nothing published. Recovery is discarding it — **everything the release wrote and everything +it created**, since a created file is untracked and survives a discard aimed at modified files, leaving the tree dirty +enough that the next release refuses to start. No compensation or rollback machinery is needed, and specifying any would +be building for an incident that cannot happen. + +> **Corrected in R3.** The sentence above originally read "leaves local modifications… Recovery is discarding them", +> written before the release could create anything. R2 gave D34's gate-stop recovery the created-file correction and left +> this one in its pre-creation vocabulary — so the spec rendered the stale version of the same hazard two paragraphs from +> the corrected one ([F70](review-findings.md#f70-the-partial-write-recovery-does-not-discard-what-the-release-created)). And it settles what stops a release cut from a branch where a step has not landed: the gate, not a pull-request check that may never have run. @@ -1134,8 +1141,10 @@ for a plugin the release did not bump, that is the version it already has, read record and never from a listing. > **Corrected in R2.** Three claims below did not survive review and are corrected here rather than rewritten in place, -> so the reasoning that produced them stays legible. **(1)** Creation is scoped to the two channel-two targets, not all -> four — see [D36](#d36-a-release-creates-what-it-can-derive-and-stops-at-what-must-be-authored). **(2)** "Creating an +> so the reasoning that produced them stays legible. **(1)** Creation is scoped — to the two channel-two targets in R2, +> and narrowed again in R3 to channel two's listing entry alone, once that decision was found to contradict itself. A +> release creates no record on either channel. See +> [D36](#d36-a-release-creates-what-it-can-derive-and-stops-at-what-must-be-authored). **(2)** "Creating an > entry [in channel two's listing] means adding the plugin's membership and nothing more" is false: that entry also > carries the policy deciding whether the plugin is installable, and a per-plugin record carries the plugin's authored > storefront presence. Also D36. **(3)** "There is no plugin for which the phrase is undefined" is false for the @@ -1339,7 +1348,7 @@ gate." **Driven by findings:** F38 **Linked technical notes:** — -**Dependent decisions:** D24, D28, D31 +**Dependent decisions:** D24, D28, D31, D36, D37 **Referenced in spec:** Alternate flows and states ### D35: An unreadable record or version is surfaced, not skipped @@ -1399,9 +1408,28 @@ hand-editing slip during steps 1 or 4, or an interrupted write from the release' ### D36: A release creates what it can derive and stops at what must be authored -**Outcome.** Creation reaches the two channel-two targets and stops there. A plugin missing from a channel-one target, -a plugin whose publishing version cannot be determined, and a plugin with no authored storefront presence are gaps the -release names and refuses rather than gaps it closes. +**Outcome.** Creation reaches **channel two's listing entry**, and stops there. A plugin with no record on a channel it +belongs in, a plugin missing from channel one's listing, and a plugin whose publishing version cannot be determined are +gaps the release names and refuses rather than gaps it closes. + +> **Corrected in R3, because this decision contradicted itself.** As written in R2 it committed creation to "the two +> channel-two targets" and, three lines later, made "a plugin with no authored storefront presence" a refusal — while its +> own rationale established that the channel-two **per-plugin record is** that presence. Those cannot both hold: a record +> that is the presence cannot be created from nothing. The decision even said so in one sentence without noticing — +> "a release creates the two channel-two records… **for a plugin whose presence a person already authored**", which +> describes a record that already exists. +> +> The boundary this decision drew is real; it fell one target to the left of where the decision put it. A listing entry +> is derivable — a name, a path, and terms identical across every entry. A record is authored. So creation reaches the +> entry and nothing else. +> +> Three consequences the R2 text got backwards, now corrected in the spec: the Linear plugin is **not** a case a release +> repairs (it is the unauthored-presence stop, closed by a person at step 1, as step 1 always said); a new plugin merged +> past the check **stops** the next release rather than costing lateness; and step 1 is therefore a binding constraint +> before step 3 ([D41](#d41-step-1-precedes-the-release-repair-because-an-unauthored-presence-stops-a-release)). +> +> Raised independently by all three R3 specialists +> ([F67](review-findings.md#f67-d36-commits-creation-to-two-targets-and-its-own-boundary-permits-one)). **Rationale.** D31 committed the release to creation on all four targets and described a created record as carrying a version and, on channel two's listing, "membership and nothing more". Both are wrong on disk, and the second is the @@ -1418,15 +1446,22 @@ A release that composed that prose would be publishing writing nobody authored t doing it unattended. This is not a capability the release lacks in a way worth fixing; it is a boundary worth keeping. The version is derivable and the presence is not, and the honest line runs exactly there. -Scoping creation to channel two follows from where the evidence is. The Linear plugin is missing from channel two, live, -today — that is the incident. Nothing has ever been missing from channel one's listing, and nothing can be missing from -channel one's per-plugin record, because carrying one is part of what makes a directory a plugin (D19). Committing the -release to create the channel-one listing entry would be building the most expensive half of the capability — the half -that has to author a description — for the case with no members. That is the symmetry-and-completeness reasoning this -specification rejects elsewhere, and applying it here would have been the specification failing its own test. +Scoping creation away from channel one follows from where the evidence is. Nothing has ever been missing from channel +one's listing, and creating that entry is the half that would have to author a description. Committing the release to it +would be building the most expensive half of the capability for the case with no members — the symmetry-and-completeness +reasoning this specification rejects elsewhere. + +What is left is small and true: **a release creates a channel-two listing entry for a plugin whose record already +exists.** Everything else is a gap it names. That capability has no live instance either, and it is kept on the +forward-looking argument D31 made rather than on the Linear incident: a contributor who authors the record and forgets +the entry is the shape it serves, and the entry is derivable, so the behavior is close to free in the way D29's +comparison is free. The Linear incident it was originally cited for is closed by a person at step 1, because the thing +missing there is the record, not the entry. -What is left is small and true: a release creates the two channel-two records, at a version it can derive, for a plugin -whose presence a person already authored. Everything else is a gap it names. +**The release does not create a record on either channel, and the reason is one rule, not two.** Channel two's record is +the plugin's presence; channel one's record is what makes the directory a plugin at all (D19). Neither is something a +release can derive: the first is prose a person writes, and the second, if absent, means a person is taking the plugin +apart. In both cases the honest move is to name it and stop. **Evidence.** codebase — a channel-two per-plugin record carries `keywords` and an `interface` block (`displayName`, `shortDescription`, `longDescription`, `developerName`, `category`, `capabilities`, `websiteURL`, `defaultPrompt`), @@ -1450,11 +1485,55 @@ gate stop, over keeping creation universal. - _Invent a version-inference rule for a plugin with no channel-one record._ Rejected: zero members, and it is machinery for a shape nothing schedules. The gate already has the right verb for it. -**Driven by findings:** F46, F47, F48 +**Driven by findings:** F46, F47, F48, F67 (correction) **Linked technical notes:** — -**Dependent decisions:** D19, D25, D29, D31, D35 -**Referenced in spec:** Channels and targets, Outcome, Primary flow (Step 1, Step 3), Alternate flows and states, Edge -cases and failure modes, Coordinations +**Dependent decisions:** D19, D25, D29, D31, D35, D38, D39, D40, D41 +**Referenced in spec:** Channels and targets, Outcome, Actors and triggers, Primary flow (binding constraints, Step 1, +Step 3), Alternate flows and states, Edge cases and failure modes, Coordinations, Deferred (YAGNI) + +### D41: Step 1 precedes the release repair, because an unauthored presence stops a release + +**Outcome.** Step 1 is a binding constraint before step 3. The execution order is unchanged; what changes is that the +adjacency is now named rather than left in the free set. + +**Rationale.** D18 says the constraints are named and the rest are free. This one was not named, because when D18 was +written the release could not refuse over anything a step of this plan produced. D36 changed that and the constraint list +was not revisited — which is the same omission F44 caught, in the other direction. + +Walk it. Step 3 lands without step 1. The release derives its plugins from the repository (D5), so it now sees the Linear +plugin. The Linear plugin belongs in all four targets (D19). It has no channel-two record, which is no authored presence, +which is a gap the release refuses (D36). There is no bypass (D28). So every release stops until someone writes that +presence by hand — and writing it by hand **is step 1**, now performed mid-release under exactly the pressure D34's +rationale identifies as what makes people ship around gaps. + +This is a real freeze, and it is worth being precise that it is not the freeze F44 refuted. F44's was over version drift, +which a repairing release closes; that one never existed. This one is over a presence a release cannot write, and it is +genuine. Both statements hold at once: a release repairs a stale version and refuses an unwritten presence. + +The ordering was already the plan's, for a different reason — step 1 goes first because it is the only live broken +promise (D11). So this costs nothing to honor and only needed saying. D38's own rejected alternative applies in the +affirmative direction: a binding constraint held in place by a false reason is how the next reviewer gets misled, and so +is a real constraint held in place by nothing. + +**Evidence.** codebase — the Linear plugin's directory carries a channel-one manifest and skills and no `.codex-plugin/` +at all, verified directly; every other non-bundle plugin has one. codebase — with step 1 landed, the gate is green on +arrival at step 3: every plugin's channel-one records agree internally and every listing entry resolves, so the hazard is +purely the ordering. codebase — the release hard-stops on a dirty tree, so the recovery is D34's discard-author-commit- +rerun rather than a quick edit mid-run. + +**Rejected alternatives.** + +- _Leave it in the free set._ Rejected: it is not free, and D18's "the rest are free" actively licenses the mistake. +- _Have the release create a placeholder presence so the constraint disappears._ Rejected: that is D36's stub + alternative, already rejected, and it would trade a named ordering for an unnamed storefront lie. +- _Add a per-plugin exception so the release ignores the Linear plugin until step 1 lands._ Rejected: a configurable + exception mechanism for one transient case, which is the reasoning the bundle deferral already refuses. + +**Driven by findings:** F68 +**Linked technical notes:** — +**Dependent decisions:** D5, D11, D18, D19, D28, D36 +**Referenced in spec:** Primary flow (binding constraints, Step 1, Step 3), Alternate flows and states, Edge cases and +failure modes ### D37: The release commits every target it writes @@ -1577,9 +1656,13 @@ worry exists (see Deferred (YAGNI)). **Evidence.** codebase — the release prints a version plan with one line per plugin, in the vocabulary bumped / unchanged at version / new at version (`.claude/skills/han-release/SKILL.md:306-315`, `:220-226`), with no term for a -created record; `:219-221` prompts for nothing when no plugin needs confirmation. Behavioral — D12 already establishes -that naming the full set at once is what the release owes a maintainer; this applies the same principle in the -affirmative direction. +created record; `:219-221` prompts for nothing when no plugin needs confirmation. + +**No evidence tier applies to the second half of this decision**, and that is stated rather than dressed up. That +reporting is *owed* rests on consistency with D12's principle (naming the full set at once is what the release owes a +maintainer), applied in the affirmative direction. An internal cross-reference is not evidence; it is an argument. It is +a good argument, and the codebase evidence above independently establishes that the report has no vocabulary for a +created record today — but nobody has asked for this and no incident names it. **Rejected alternatives.** @@ -1597,8 +1680,23 @@ interactions ### D40: A half-finished removal is not a state the release guesses at **Outcome.** A plugin the repository still carries is a plugin the rule expects in every target it belongs in. A -directory that remains is a plugin, and its absence from a target is a gap the release closes — so removing a plugin -means removing the directory. +directory that remains is a plugin — so removing a plugin means removing the directory. What the release does about the +leftovers follows D36: a deleted listing entry is put back, and a deleted record is a gap the release names and stops on. + +> **Corrected in R3.** This decision said a remaining directory's "absence from a target it belongs in is a gap **the +> release closes**", unqualified across all four targets. D36 — added in the same round, and absent from this decision's +> dependent list, which is the same tell F50 used on D6/D31 — makes a missing record an unconditional stop. So the +> unqualified claim was false for three of the four targets. +> +> Worse, this decision falsified one of D36's own claims and neither noticed. D36 asserted that no plugin has ever been +> missing from a channel-one target; this decision's own worked scenario — a directory whose records were deleted — is +> precisely that state. R3 caught the contradiction by reading the two together +> ([F72](review-findings.md#f72-d40-says-the-release-closes-the-gap-where-d36-says-it-stops)). +> +> The behavioral answer survives intact: the rule does not infer intent from absence, and removal means removing the +> directory. Only the mechanism was overstated. The corrected version is arguably better than what it replaced — a +> half-finished removal is now mostly **named** rather than silently undone, which is what a person half-way through +> taking something apart actually needs. **Rationale.** Removal is not hypothetical: the release process already has a rule for it, versioning a removed child as a major change. A removal that lands whole is invisible to this rule and needs nothing. A removal that lands @@ -1617,9 +1715,14 @@ directory goes. That gives the maintainer one thing to get right instead of four rest of this work is built on. **Evidence.** codebase — the release process versions "a child was removed from the suite" as a major bump, so removal -is an anticipated event rather than a hypothetical. Behavioral — D19 already defines a plugin by its directory and -manifest rather than by its listing membership, so this is that definition applied to the removal direction rather than -a new rule. +is an anticipated event rather than a hypothetical. + +**No evidence tier applies to the resolution itself**, and R3 was right to flag that the original wording obscured it. +That a remaining directory is still a plugin rests on consistency with D19's definition, applied in the removal +direction — an argument, not evidence. The codebase evidence establishes only that removal is anticipated, not that this +particular answer is correct. It is worth noting that the unqualified version of this argument is exactly what R3 +falsified against D36: reasoning from another decision's definition is how this decision got the mechanism wrong while +getting the principle right. **Rejected alternatives.** @@ -1629,9 +1732,9 @@ a new rule. - _Leave removal undefined._ Rejected: the release would silently recreate the records a half-finished removal deleted, which is a silent write to what Han publishes. -**Driven by findings:** F58 +**Driven by findings:** F58, F72 (correction) **Linked technical notes:** — -**Dependent decisions:** D19, D29, D31 +**Dependent decisions:** D19, D29, D31, D35, D36 **Referenced in spec:** Alternate flows and states, Edge cases and failure modes ## Trivial decisions diff --git a/docs/plans/han-publishing-cleanup/artifacts/review-findings.md b/docs/plans/han-publishing-cleanup/artifacts/review-findings.md index 183eb0c9..a21f0aff 100644 --- a/docs/plans/han-publishing-cleanup/artifacts/review-findings.md +++ b/docs/plans/han-publishing-cleanup/artifacts/review-findings.md @@ -1169,6 +1169,392 @@ F32 used: the qualitative point does not depend on the number, so the number sho **Changed in tech-notes:** — +### F67: D36 commits creation to two targets, and its own boundary permits one + +**Agent:** `junior-developer` (JD-R3-01), `devops-engineer` (F67), `adversarial-validator` (V1) — all three R3 +specialists independently, from three different starting points. + +**Category:** two resolutions from the same round contradicting each other — the class R2's own F44 diagnosed in R1 + +**Finding.** R2 resolved F46 (a channel-two record carries authored prose) and F47 (creation is committed on four +targets with evidence for two) into **one decision, D36**, in a single pass, and never read the halves against each +other. They are incompatible: + +- D36's Outcome commits creation to **two** channel-two targets. +- D36's Outcome, three lines later, makes a plugin with **no authored storefront presence** a gap the release refuses. +- D36's own rationale establishes that the channel-two **per-plugin record is that presence** — "display name, a short + description, a long description, a category, and a set of example prompts — none of which derives from anything the + repository holds." + +A record that *is* the presence cannot be created from nothing. So creation reaches **one** target, the listing entry. +D36 says the contradiction out loud in a single sentence and does not notice it: "a release creates the two channel-two +records… **for a plugin whose presence a person already authored**." If the presence is authored, the record exists, and +there is nothing to create. + +The consequences are not cosmetic. Five spec sentences were false: + +- "the second is why the Linear plugin's shape is repairable" — it is not. `han-linear` has no channel-two record, so it + is the unauthored-presence stop. **The motivating case is closed by a person at step 1, not by a release** — which is + what step 1 has said all along ("A person writes this record, and that is the point… This is the same boundary step 3's + release stops at"). Step 3 contradicted step 1. +- "This is the ordinary path, not the failure path" — false for the target it named. +- "the next release creates the missing records itself… so the signal being advisory costs lateness rather than + correctness" — false, and see F69. +- "A branch missing steps 1 or 4 is repaired by the release rather than refused" — false for step 1, and see F68. +- The Outcome and the Actors table both said "creating what is missing", unqualified. + +**Evidence considered.** codebase — verified directly during consolidation: the set of plugins carrying a +`.codex-plugin/` directory and the set of entries in the channel-two listing are **identical** (nine each), and +`han-linear` is in neither, so the "record present, entry absent" state has never existed and `han-linear` is missing +both. codebase — a channel-two listing entry carries `name`, `source.path`, a `policy` block byte-identical across all +nine entries, and a `category` identical across all nine: it is genuinely derivable. A per-plugin record is not. codebase +— D36's rejected alternatives close the escape routes: the user rejected both "the release authors the presentational +content" and "the release creates a stub". + +**Resolution.** Creation reaches channel two's listing entry and stops there. A plugin with no record on a channel it +belongs in is a gap the release names and stops on, because the record is the authored presence and there is nothing to +derive. The five false sentences are corrected, and the spec now says plainly that the Linear plugin is closed by a +person rather than by the release. + +The surviving capability has no live instance, which is the test F47 used to cut the other half. It is kept — but on the +honest justification rather than the incident: it serves a contributor who authors the record and forgets the entry, and +the entry is derivable, so the behavior is close to free in the way D29's comparison is free. Recorded on D36. + +**Resolved by:** user input (option: creation reaches the listing entry only), on evidence. + +**Raised in round:** R3 + +**Changed in plan:** Outcome, Actors and triggers, Primary flow (binding constraints, Step 3), Alternate flows and +states, Edge cases and failure modes, Coordinations, Deferred (YAGNI), Summary + +**Changed in decision log:** D36 corrected; D41 added + +**Changed in tech-notes:** — + +### F68: Step 1 became a binding constraint and the constraint list did not gain it + +**Agent:** `devops-engineer` (F68) + +**Category:** unstated ordering constraint created by a decision written after the constraint list + +**Finding.** D18 says the binding constraints are named and **the rest are free**. Step 1 → step 3 is not named. After +D36 it is binding, and the walk is short: step 3 lands without step 1 → the release derives its plugins from the +repository (D5) and sees `han-linear` → `han-linear` is a plugin and belongs in all four targets (D19) → it has no +channel-two record, so it has no authored presence → the release refuses (D36) → there is no bypass (D28). **Every +release freezes until someone writes that presence by hand**, which is step 1, performed mid-release under the pressure +D34's rationale names as what makes people ship around gaps. + +This is a real freeze, and R2 removed a false one from the same list in the same round. F29 claimed a freeze over version +drift; F44 correctly refuted it, because a repairing release closes drift. D36 then created a genuine freeze over a +different gap, and the constraint list — rewritten in R2 precisely to remove the false one — never gained the true one. + +**Evidence considered.** codebase — verified directly: `han-linear/` contains `.claude-plugin/` and `skills/` only, no +`.codex-plugin/`; every other non-bundle plugin has one. codebase — with step 1 landed the gate is green on arrival at +step 3, so the hazard is purely the ordering, which is why it is invisible on the happy path and licensed by D18's "the +rest are free". codebase — the release hard-stops on a dirty tree, so recovery is D34's discard-author-commit-rerun +rather than a quick edit. + +**Resolution.** The fourth binding constraint is named, with its reason rather than just its order. Costs nothing to +honor — step 1 was already first, for a different reason (D11) — and needed saying. Recorded as D41. The branch-cut flow +is corrected to separate step 1 (refused) from step 4 (repaired). + +**Resolved by:** evidence. + +**Raised in round:** R3 + +**Changed in plan:** Primary flow (binding constraints, Step 1, Step 3), Alternate flows and states, Edge cases and +failure modes + +**Changed in decision log:** D41 added + +**Changed in tech-notes:** — + +### F69: "Costs lateness rather than correctness" is false, and it is the sentence paying for D32's downgrade + +**Agent:** `devops-engineer` (F69), with `junior-developer` (JD-R3-01) reaching the same sentence. + +**Category:** behavioral commitment resting on a premise a later decision removed + +**Finding.** The spec justified tolerating an advisory check with one sentence: "If they merge past it — which they can — +the next release creates the missing records itself, **so the signal being advisory costs lateness rather than +correctness**." + +A brand-new plugin is by definition the case with **no authored channel-two presence** — nobody has written its display +name or its example prompts, because the plugin is new. So per D36 the next release does not create its records; it +**stops**. Merging past the advisory check does not cost lateness. It freezes the release pipeline until a person writes +the presence. + +This matters beyond the sentence. F28 and D32 downgraded the enforcement claim honestly, and the price of that downgrade +was exactly this sentence: the check may be advisory **because the release repairs what gets merged past it**. Post-D36 +the release does not repair the new-plugin case, which is the single case the advisory check exists to catch. The +downgrade's justification was gone and the downgrade was still on the page. + +**Evidence considered.** codebase — the release treats a new child as a real, anticipated shape ("`baseline = current`, +`target = current`, no bump"), so this is not hypothetical. codebase — the interface block is what a new plugin would +need authored and would not have. + +**Resolution.** The two costs are separated rather than flattened: a missing listing entry costs lateness, and a missing +record costs a stopped release. The spec states the more likely case honestly — a brand-new plugin has no presence by +definition, so merging past this signal can stop the next release. And it replaces the downgrade's justification with one +that survives: the advisory check is tolerable because the release refuses to publish something nobody wrote, which is a +protection rather than a repair. + +**Resolved by:** evidence. + +**Raised in round:** R3 + +**Changed in plan:** Alternate flows and states + +**Changed in tech-notes:** — + +### F70: The partial-write recovery does not discard what the release created + +**Agent:** `devops-engineer` (F70) + +**Category:** recovery path that does not survive creation — F50's error class, surviving R2 + +**Finding.** The spec carries two recoveries. R2 fixed one and left the other in the vocabulary that predates creation: + +- Gate stop: "everything it wrote **and everything it created, because a created file the release leaves behind is + untracked, survives a careless cleanup, and keeps the tree dirty**." +- Partial write: "Recovery here *is* **discarding local changes** and re-running." + +A release that fails partway through writing four targets can have created a record or a listing entry before failing. +"Discarding local changes" is precisely the phrase F57 established does not reach untracked files. The next release then +hard-stops on a dirty tree — the loop D34 was completed to close, reachable through the door D34 did not cover. + +The root is traceable: D24's partial-failure rationale reads "a failure mid-write leaves **local modifications** and +nothing published. Recovery is discarding them." D24 was written before D31 and D36 existed, and the spec renders D24's +sentence verbatim. R2's F54 named only the gate-stop flow, and F57 was folded into D34; neither reached D24. + +**Evidence considered.** codebase — the release derives its clean-tree check from `git status --porcelain`, which counts +untracked files, and hard-stops when it is non-empty. D34's own R2 note already states the fact. + +**Resolution.** One clause: the partial-write recovery discards everything the release wrote and everything it created, +for the same reason the gate-stop recovery does. D24's rationale is corrected too, since that is the sentence the spec +was rendering — otherwise the next reader reintroduces it. Does not reopen D24's placement or D34's ordering. + +**Resolved by:** evidence. + +**Raised in round:** R3 + +**Changed in plan:** Alternate flows and states + +**Changed in decision log:** D24 corrected + +**Changed in tech-notes:** — + +### F71: D35 and D38 give different answers for a broken version value + +**Agent:** `devops-engineer` (F71), with `adversarial-validator` (V3) finding the adjacent dead text. + +**Category:** rule stated before the release could repair, never revisited + +**Finding.** Two adjacent Edge-cases rows answered the same shape of question with different care. The disagreement row +was **split per surface** by R2 ("On a release it is repaired rather than reported"). The broken-value row stayed +**flat**: "Check fails, naming it, exactly as a disagreement does." + +But post-D38 a release writes the publishing version onto every version record it can read. A parseable record whose +version is an empty string is perfectly writable — so the release overwrites it exactly as it overwrites a stale one, and +the gate never sees it. D35 says it fails the check; D38 says the release corrects it. Neither is qualified by surface. + +This is F44's own argument one level down. D38 rejected narrowing the repair precisely because it "requires explaining +why writing a version onto a missing record is safe and writing the same version onto a stale one is not — two identical +acts, treated differently to protect an argument". The broken-value row was that argument's third instance. D35's +rationale for the value case is explicitly a *comparison* concern ("a naive equality comparison treats two empty values +as agreement"), which is a check-surface concern that the release-surface repair moots. D35 simply predates the repair. + +**Evidence considered.** codebase/provided — D35's rejected alternative is about a **parse failure**, not a broken value +inside a parseable record, so it does not settle this case. + +**Resolution.** The row is split per surface like its neighbour: reported on a pull request, overwritten on a release, +because it is the same act. The boundary against D35 is stated explicitly — a broken value inside a readable record is +repairable; a record the release cannot parse is not. `adversarial-validator` separately found the sentence "so a +disagreement it caused is the only one the gate can still see" describes a state unreachable under D24's write-then-gate +ordering; it is cut as dead text left from before D38. + +**Resolved by:** evidence. + +**Raised in round:** R3 + +**Changed in plan:** Edge cases and failure modes + +**Changed in tech-notes:** — + +### F72: D40 says the release closes the gap where D36 says it stops + +**Agent:** `adversarial-validator` (V2), with `junior-developer` (JD-R3-03) reaching the same collision. + +**Category:** two R2 decisions from the same round that never met + +**Finding.** D40's dependent-decisions list read "D19, D29, D31" — it did **not** list D36. That is the identical tell R2 +used to catch F50 ("The tell that the two decisions never met: D6's dependent-decisions list did not include D31"). +Applying R2's own diagnostic to R2's own output finds the same defect. + +D40 said a remaining directory's "absence from a target it belongs in is a gap **the release closes**", unqualified +across all four targets. D36 — added in the same round — makes a missing record an unconditional stop. So D40 was false +for three of the four. + +And D40 falsified one of D36's own claims without either noticing. D36 asserted that no plugin has ever been missing from +a channel-one target; D40's own worked scenario ("a plugin's directory remains but its records were deleted") is exactly +that state, reachable by deleting a manifest first — an ordinary order of operations for a removal that then stalls. R2 +introduced the counter-example to its own claim in the same round. + +**Evidence considered.** provided — D40's and D36's texts read against each other. codebase — the release already +versions a removed child as a major bump, so removal is anticipated. + +**Resolution.** D40's behavioral answer survives — the rule does not infer intent from absence, and removal means +removing the directory. Its mechanism is corrected to follow D36: a deleted listing entry is put back, a deleted record +is a stop. The corrected version is arguably better than what it replaced: a half-finished removal is now mostly +**named** rather than silently undone, which is what a person half-way through taking something apart actually needs. +D36 and D35 are added to D40's dependent list, and D36's "has never happened" absolute is removed. + +**Resolved by:** evidence. + +**Raised in round:** R3 + +**Changed in plan:** Alternate flows and states, Edge cases and failure modes + +**Changed in decision log:** D40 corrected; D36 corrected + +**Changed in tech-notes:** — + +### F73: Step 4 is unhedged against Open item 2, and the reason that survives it goes unstated + +**Agent:** `junior-developer` (JD-R3-05) and `adversarial-validator` (V4), convergent. + +**Category:** behavioral commitment resting on a recorded unknown — the class F35 established + +**Finding.** Two problems with D38's restated justification for step 4 ("the numbers are right on merge rather than at +the next release"). + +**It is contingent and the spec did not hedge it.** Open item 2 names step 4 explicitly: if the client resolves from the +tag rather than the branch, "steps 1 and 4 reach users at the next release rather than on merge." Under tag resolution +step 4's benefit is not "less" (D38's word) — it is **zero**, because the only revision a user resolves is one a release +produced, and a repaired release corrects those numbers itself. Step 1 **is** hedged against Open item 2 — that was F35's +fix in R1. Step 4's paragraph carried no hedge, from the same open item, which names it. F35's exact asymmetry, +reintroduced by R2 at the step R2 rewrote. + +**The non-contingent reason was missing.** Step 7 claims flatly that the check is green on the day it arrives. What makes +it green is steps 1 and 4. Drop step 4 and, absent a release cut between step 3 and step 7, the check lands **red against +eight disagreements** — D1's restated failure mode arriving on the day the check lands. That reason holds regardless of +Open item 2 and is stronger than the one D38 chose. D38 reduced step 4's justification and did not notice it had removed +the support from a flat claim two steps later. + +**Evidence considered.** provided — Open item 2's own text names steps 1 and 4; D38's rationale concedes the contingency +("matters more if… and less if") while the spec states it flat. + +**Resolution.** Both added. Step 4 is hedged the way step 1 is, and states honestly that under tag resolution its +user-facing benefit is nothing and what survives is that the repository stops publishing numbers it knows are wrong. The +reason that does not depend on the unknown — step 4 is what makes step 7's check green on arrival — is stated and is now +the primary one. + +**Resolved by:** evidence. + +**Raised in round:** R3 + +**Changed in plan:** Primary flow (Step 4) + +**Changed in tech-notes:** — + +### F74: The "gaps a release cannot close" list is four different lists + +**Agent:** `junior-developer` (JD-R3-07) + +**Category:** ambiguity in a rule stated more than once + +**Finding.** Four enumerations, no two alike: Step 3's prose (5 items), Alternate flows (the same 5), D36's Outcome (3, a +different set), and the Edge-cases table (7). Two omissions matter. **"A plugin missing from a channel-one target"** is +D36's own headline scoping decision — what F47 was entirely about — and was absent from both prose lists, including the +one ten lines below the paragraph that states it. **"An unreadable storefront listing"** is F59's R2 addition; it landed +only in the table, and the prose said "a record it cannot read", which the glossary distinguishes from a listing. An +implementer working from the prose builds neither stop. + +**Evidence considered.** provided — the four enumerations read against each other. + +**Resolution.** The list is written once, in Step 3, and the other locations point at it rather than restating it. Post- +F67 it is also shorter than any of the four, because "missing from a channel-one target" and "no authored presence" +collapse into one rule: a plugin with no record on a channel it belongs in. + +**Resolved by:** evidence. + +**Raised in round:** R3 + +**Changed in plan:** Primary flow (Step 3), Alternate flows and states + +**Changed in tech-notes:** — + +### F75: D39's report lands after the last thing a person can stop, and the deferral it justifies says otherwise + +**Agent:** `junior-developer` (JD-R3-04) + +**Category:** an R2 resolution resting on a fact another R2 finding falsified in the same round + +**Finding.** The Deferred entry deferred the first-publication confirmation because "the release now reports what it +created (D39), which is **the strictly simpler thing that satisfies the same concern**". The concern, one line above: +"Creation makes a directory publicly installable **with no sign-off**." + +Reporting is notice, not sign-off — and on the default path the notice is not separated from the publish by any prompt. +The release prints the prepared release and, because the publish prompt is off by default, continues straight to +committing, tagging, and pushing. **F51 established that default in this same round**, and then a deferral was written +that depends on the opposite. + +The other candidate moment fails too. D39 says the report lands "alongside the version plan it already reports", but the +version plan's mandatory confirmation runs **before** the writes, and a creation-only release — the `han-linear` shape, +new plugin, no bump — needs no confirmation, so that prompt does not fire at all. The report cannot land at the only +prompt that always fires, because on the motivating case it does not. + +**Evidence considered.** codebase — the release's mandatory plan confirmation runs pre-writes and does not prompt when +nothing needs confirmation; the publish prompt is gated on a flag defaulting to false; Step 8 then commits, tags, pushes. + +**Resolution.** The claim is downgraded honestly rather than the mechanism grown — the same shape D32 and F52 already +applied twice. The deferral now rests on what is true: the case has never occurred, and a plugin cannot reach a storefront +without a person having written its presence first (D36), which is a sign-off of a kind. The spec states plainly that +reporting is notice after the fact on the default path, not sign-off. D39's own evidence block is corrected too — see +F76. + +**Resolved by:** evidence. + +**Raised in round:** R3 + +**Changed in plan:** Deferred (YAGNI), User interactions + +**Changed in decision log:** D39 corrected + +**Changed in tech-notes:** — + +### F76: Two R2 decisions cite an evidence trust class the evidence rule does not define + +**Agent:** `adversarial-validator` (V6) + +**Category:** evidence-quality violation + +**Finding.** The evidence rule defines exactly three trust classes: codebase, web, provided. Two R2-added decisions — D39 +and D40 — cite a fourth, "Behavioral", to support load-bearing claims. Neither citation is any of the three: it is one +decision's rationale citing another decision's rationale as if that were independent evidence. + +The test is whether discounting it changes the conclusion. For D40, **yes**: its only non-"Behavioral" evidence +establishes that removal is *anticipated*, not that D40's specific "the release closes the gap" behavior is *correct* — +which is exactly what F72 shows is false. The mislabeled evidence was standing in for a conclusion that does not hold. + +**Evidence considered.** provided — `han-planning/references/evidence-rule.md` defines the three classes; the decision +log's own header restates them. + +**Resolution.** Both are relabeled to say what they are: no evidence tier applies, and the claim rests on consistency +with another decision — an argument, not evidence. D40's note additionally records that reasoning from another decision's +definition is precisely how it got the mechanism wrong while getting the principle right, which is the lesson rather than +a footnote. + +**Resolved by:** evidence. + +**Raised in round:** R3 + +**Changed in plan:** — + +**Changed in decision log:** D39, D40 corrected + +**Changed in tech-notes:** — + ## Rejected findings - **F65 (rejected): D18's execution-order numeral list is not inconsistent with its gloss.** `edge-case-explorer` read @@ -1186,6 +1572,18 @@ F32 used: the qualitative point does not depend on the number, so the number sho `docs/concepts.md:225-227` documents the identical transitive-necessity pattern for the `han-planning` and `han-coding` edges. The declaration is real and deliberate. The gap was that D8 and D25 never stated the test that distinguishes it from `han-linear`'s — recorded as F62 rather than as a deletion. Step 5 remains three. +- **F77 (rejected): there is no case where a release should decline to overwrite a stale version record.** Raised as an + attack on D38 by `adversarial-validator` (V3), which searched for a legitimate reason a plugin might carry a + deliberately-ahead or asymmetric channel-two version and found none: D10's one-version-per-plugin model treats the + direction of disagreement as irrelevant by design, and no evidence at any tier suggests an intentional case. Recorded + as a failed falsification rather than dropped, because D38 is the round's largest reversal and the attack was the right + one to make. The evidence rule forbids raising an unsupported hypothetical as a corrective claim, so no change. +- **F78 (rejected): D37 does not collide with D34.** Attacked independently by all three R3 specialists at the caller's + request, and refuted by all three. The two decisions are keyed to mutually exclusive gate outcomes: D37 governs the + passing branch ("the commit it tags"), D34 the stopping branch, and a stop reaches no commit at all — so they compose + rather than conflict. The adjacent gap (a technical failure during commit/tag/push *after* a gate pass) is already + covered by D24's re-entrancy evidence. No change; recorded because a verified clean seam is worth as much as a defect + when the round's whole question is whether R2's decisions cohere. ## Minor edits @@ -1210,6 +1608,16 @@ F32 used: the qualitative point does not depend on the number, so the number sho The behavioral commitment survives either way (the failure can be named by the directory it was found in), so the argument is softened rather than the outcome changed. — `adversarial-validator` (V4) — D35 (rationale; behavior unchanged) +- F79: D34's dependent-decisions list omitted D37, the decision that governs the other side of the same gate outcome — + the cheap signal that would have caught F67 and F72 had it been maintained. The seam itself holds (F78), so this is + bookkeeping. — `devops-engineer` (M1), `junior-developer` — D34 (dependent decisions; behavior unchanged) +- F80: D39 says a release reports what it "creates or corrects", which post-D38 means every plugin and every target on + every release forever — a report nobody reads, which is D1's restated failure mode arriving through D39's own remedy. + Corrected to records whose value it **changed**, plus creations. — `junior-developer` (M1) — User interactions; D39 +- F81: Step 3 justified creation's scope by citing the Linear incident ("That is where the evidence is… it is live + today"), which post-F67 is a case creation cannot serve. The behavior survives on D31's forward-looking argument — a + contributor who authors the record and forgets the entry — and the incident citation is replaced by it. Same shape as + F43 and D29: the behavior stands, the justification does not. — `devops-engineer` (M2) — Primary flow (Step 3); D36 - F62: D8 verifies `han-atlassian`'s `han-core` edge "by use, not by self-description" and calls the manifest true on the strength of it, while three other declared edges went uncited — and one of them (`han-communication`) would fail the very test D8 states it is applying. All four were verified in R2 and all four are real, but the distinction that makes diff --git a/docs/plans/han-publishing-cleanup/artifacts/review-iteration-history.md b/docs/plans/han-publishing-cleanup/artifacts/review-iteration-history.md index 93ac6d9a..4e657d44 100644 --- a/docs/plans/han-publishing-cleanup/artifacts/review-iteration-history.md +++ b/docs/plans/han-publishing-cleanup/artifacts/review-iteration-history.md @@ -193,3 +193,94 @@ each other are where the next round's findings live. R3 should verify that D36 t other or with what they corrected — particularly D36's authored-presence gate stop against D31's "ordinary path" framing, and D37's commit commitment against D34's recovery order. The specification's factual foundation is now well-tested and should not need re-litigating. + +## R3 + +**Mode:** team. **This is the final round — the large-size cap is 3.** + +**Spec-aware mode:** engaged. Every agent received the behavioral-level brief, the YAGNI brief, and the evidence brief, +plus R1's and R2's findings. + +**Size:** large — unchanged. Round cap 3, team cap 5. + +**Specialists engaged:** `junior-developer`, `adversarial-validator`, `devops-engineer`. All three returned output. +`evidence-based-investigator` and `edge-case-explorer` were **dropped for this round** and the reason is worth recording: +R2 used the investigator to re-verify the spec's entire factual foundation — the drift table, the eleven-release count, +the live platform configuration, the closed set of three untrue declarations — and every claim held. Re-running it would +have spent the last round re-confirming settled facts rather than testing R2's own reasoning, which R2's next-step note +correctly identified as the place the findings would be. That judgment paid: R3's findings are all reasoning defects, and +not one of them touches a fact. + +**Findings raised:** F67, F68, F69, F70, F71, F72, F73, F74, F75, F76 (major); F79, F80, F81 (minor); F77, F78 (raised +and rejected). Numbering continues from R2, which reached F66. + +**Convergence, and it is the whole story.** All three specialists independently found the same root: **D36 contradicts +itself in its own Outcome.** R2 resolved F46 and F47 into one decision in a single pass and never read the halves against +each other — committing creation to two channel-two targets while establishing, in the same decision's rationale, that +one of those targets *is* the authored presence the other half refuses to invent. A record that is the presence cannot be +created from nothing. + +That is verbatim the mistake R2 caught R1 making. R2's F44 diagnosed it precisely — "two resolutions from the same round +contradicting each other" — and then R2 did it again, in the very decision it wrote to fix the first one. The +`adversarial-validator` found it by applying R2's own diagnostic test: D40's dependent-decisions list omits D36, which is +the identical tell F50 used on D6/D31. + +**Nine of the ten major findings are downstream of that one.** F68 (step 1 became binding and the constraint list did not +say so), F69 ("costs lateness" is false), F72 (D40 says close where D36 says stop), F74 (the gap list is four lists), F81 +(the evidence citation names an incident the capability cannot serve) all fall out of F67. F70, F71, F73, F75, F76 are +its siblings: sentences written before the release could create, or before creation was scoped, that nobody re-read. + +**Surfaced to the user.** One finding required judgment only the plan's author could make: + +- F67 — how does D36's self-contradiction resolve? **User chose "creation reaches the listing entry only"** (the + recommendation), accepting three consequences stated up front: step 1 becomes a binding constraint, a new plugin merged + past the check freezes the next release until someone authors its presence, and the surviving capability has no live + instance and stands on D31's forward-looking argument rather than the Linear incident. The user also chose to apply all + nine downstream findings in this round rather than defer them to `plan-implementation`. + +**Failed falsifications, recorded because they are results.** Two attacks the caller specifically commissioned came back +clean and are logged as rejected findings rather than dropped: D37 does not collide with D34 (F78 — refuted independently +by all three specialists; the two are keyed to mutually exclusive gate outcomes), and there is no case where a release +should decline to overwrite a stale version record (F77 — no evidence at any tier supports one, so raising it would +violate the evidence rule). D38's repair scope was separately verified sound: "the version the release is publishing" is +well-defined on all three unbumped paths. + +**Changed in plan:** Outcome; Actors and triggers; Primary flow (binding constraints, Step 3, Step 4); Alternate flows +and states; Edge cases and failure modes; Coordinations; User interactions; Deferred (YAGNI); Summary; Review History. + +**Changed in decision log:** D41 added. Corrections: + +- **D36** — corrected at the root. Its boundary was real and fell one target to the left of where it put it. +- **D40** — its mechanism corrected to follow D36; its behavioral answer survives, and the corrected version is better + than what it replaced (a half-removal is named rather than silently undone). +- **D24** — its partial-failure recovery still used pre-creation vocabulary that the spec was rendering verbatim. +- **D39** — reports changes rather than every write, and its "Behavioral" evidence label corrected. +- **D34** — dependent-decisions list completed with D36 and D37. + +**Changed in tech-notes:** none. No finding across all three rounds after R1 required a new load-bearing mechanic. + +**YAGNI.** No new candidates and no new deferrals. `adversarial-validator` ran the gate over all five of R2's decisions +and both of its deferrals and found no unevidenced scope creep — the round's defects are correctness and consistency +failures in decisions that were appropriately evidenced for inclusion. F81 is the closest call: the surviving creation +capability has no live instance, which by F47's own test would cut it. It is kept because the entry is derivable (close +to D29's "free"), and the spec now says so instead of citing an incident it cannot close. + +**Stability assessment:** not stable at the cap, and honest about it. The round produced ten major findings, one of which +required user judgment and reversed part of an R2 decision. That is far past the stop rule (≤2 new findings, zero major). + +But the trajectory is clear and worth reading: R1 falsified claims about the world. R2 falsified nothing about the world +and found one decision that had not been propagated. R3 falsified nothing about the world and found one decision that +contradicted itself, plus its blast radius. Each round's defect is narrower than the last, and all of R3's are now +closed. What has never once failed re-verification is the specification's factual foundation. + +**The pattern this review should be remembered for:** three rounds running, the defect was a decision made late in a +round and not read against its neighbours. R1 did it with F29/F30. R2 did it with F46/F47 inside D36. The mechanism that +caught it both times was the dependent-decisions list — F50, F72, and F79 are all the same check. If a fourth round were +run, that check is where it should start. + +**Next step:** the round cap is reached, so no R4 runs under this skill. The specification is materially stronger than it +was two rounds ago and its remaining risk is concentrated, not diffuse: R3's own resolutions added one decision (D41) and +corrected five, and by this review's own repeated lesson, that is exactly the surface a fresh pass would probe. The +recommendation is not another full review but a targeted check of D36-as-corrected and D41 against D31, D38, D39, and D40 +before implementation planning begins — the check this specification has now failed to perform three times in a row, and +which costs one pass rather than one round. diff --git a/docs/plans/han-publishing-cleanup/feature-specification.md b/docs/plans/han-publishing-cleanup/feature-specification.md index 2a2e386c..d6796fc6 100644 --- a/docs/plans/han-publishing-cleanup/feature-specification.md +++ b/docs/plans/han-publishing-cleanup/feature-specification.md @@ -64,8 +64,9 @@ Concretely, when this work is done: following the contributor guide is not flagged by the check for doing what the guide told them ([D21](artifacts/decision-log.md#d21-the-release-procedure-documents-are-owned-by-the-step-that-falsifies-them)). - The release process starts from what is really in the repository, brings every target up to date — correcting a - version that has fallen behind and creating a channel-two record a plugin is missing, not only reporting them — and - stops rather than shipping around a gap it cannot close + version that has fallen behind and creating a channel-two listing entry a plugin is missing, not only reporting them — + and stops rather than shipping around a gap it cannot close, including a plugin whose storefront presence nobody has + written ([D5](artifacts/decision-log.md#d5-the-check-derives-the-plugin-list-from-the-repository), [D31](artifacts/decision-log.md#d31-the-release-creates-a-missing-target-at-the-version-it-is-publishing), [D36](artifacts/decision-log.md#d36-a-release-creates-what-it-can-derive-and-stops-at-what-must-be-authored)). @@ -85,7 +86,7 @@ Concretely, when this work is done: | Actor | Trigger | What they need from this feature | | ------------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------- | | Han maintainer | Works through the seven steps once | Each step lands without breaking the next; the real ordering constraints are stated | -| Han maintainer | Cuts any future release | The release brings every target up to date, creating what is missing, and refuses to ship around a gap it cannot close | +| Han maintainer | Cuts any future release | The release brings every target up to date, creating the membership it can derive, and refuses to ship around a gap it cannot close | | Contributor | Opens a pull request that adds or renames a plugin | The check tells them what is missing, and the contributor guide already told them the four targets | | Channel-two installer | Runs the documented install command for any advertised plugin | The install succeeds | | Channel-two installer | Has Han installed and expects updates | Updates are offered when releases happen | @@ -96,9 +97,17 @@ Concretely, when this work is done: Seven steps, executed in the order below. Only some adjacencies are forced; the forced ones are named and the rest are free ([D18](artifacts/decision-log.md#d18-the-execution-order-is-a-partial-order-and-the-repair-precedes-the-correction)). -The binding constraints are: **the check is last**; **the release repair precedes the version correction**; **the -declaration deletion and the document correction are one unit, shipped in a single change**; and the work-items fix is -independent of all of it. +The binding constraints are: **the check is last**; **step 1 precedes the release repair**; **the release repair +precedes the version correction**; **the declaration deletion and the document correction are one unit, shipped in a +single change**; and the work-items fix is independent of all of it. + +**Step 1 before step 3 is a real constraint and it is easy to miss.** Once the release derives its plugins from the +repository, it sees the Linear plugin — and the Linear plugin has no authored presence on channel two, which is a gap +the release refuses rather than fills. So a repository with step 3 and without step 1 stops every release until someone +writes that presence by hand, which is step 1 performed under release pressure instead of on purpose +([D41](artifacts/decision-log.md#d41-step-1-precedes-the-release-repair-because-an-unauthored-presence-stops-a-release)). +This is the freeze the next paragraph says does not exist, on a different gap. Both statements are true: a release +repairs a stale version and refuses an unwritten presence. The repair-before-correction constraint is an **ordering, not a unit**, and the difference is worth stating because this specification briefly claimed otherwise. The claim was that the gate starts refusing releases at step 3, so shipping @@ -196,30 +205,51 @@ stays absent forever. The first half is why the drift is repairable at all; the is repairable ([D31](artifacts/decision-log.md#d31-the-release-creates-a-missing-target-at-the-version-it-is-publishing)). -**Creation reaches the two channel-two targets and stops there.** That is where the evidence is: a plugin missing from -channel two is the defect this work exists to fix, and it is live today. A plugin cannot be missing from channel one's -per-plugin record, because carrying one is part of what makes a directory a plugin -([D19](artifacts/decision-log.md#d19-a-plugin-and-the-targets-it-belongs-in-are-defined-positively)); a plugin missing -from channel one's listing has never happened, and is the one creation would have to author a description for. Both are -gaps the release refuses rather than closes +**The release creates what it can derive and refuses what must be authored.** That line decides which targets creation +reaches, and it falls in exactly one place +([D36](artifacts/decision-log.md#d36-a-release-creates-what-it-can-derive-and-stops-at-what-must-be-authored)). + +**Creation reaches channel two's listing entry.** An entry there says a plugin exists, where it lives, and the terms on +which it installs — all of which the release can derive from the plugin itself, and the terms are the same terms every +other plugin already carries. Nothing about it is written prose. + +**Creation does not reach a plugin's per-plugin record on either channel, and a missing one is a gap the release stops +on.** Channel two's record does not merely carry a version alongside the plugin's storefront presence; it **is** that +presence — the display name, the short and long descriptions, the examples of what to ask it. There is no second file to +author it in. So a plugin with no channel-two record has no authored presence by definition, and a release that created +one would be inventing the prose, not deriving it. Channel one's record cannot be missing from a plugin at all, because +carrying one is part of what makes a directory a plugin +([D19](artifacts/decision-log.md#d19-a-plugin-and-the-targets-it-belongs-in-are-defined-positively)) — and if one is +deleted anyway, that is a person taking something apart, not a gap for a release to fill in. + +This is the boundary step 1 already describes from the other side. Step 1 has a person write the Linear plugin's +channel-two record because nobody else can, and that is the same wall a release meets. **The motivating case is closed by +a person, not by a release**, and the release's creation exists for the narrower thing that comes after: a contributor +who authors the record and forgets the listing entry ([D36](artifacts/decision-log.md#d36-a-release-creates-what-it-can-derive-and-stops-at-what-must-be-authored)). -A created record carries the version the release is publishing for that plugin, which for a plugin the release did not -bump is the version it already has, read from that plugin's own channel-one record and never from a listing — a listing -is one of the things this rule exists to correct, so it cannot also be the authority -([D31](artifacts/decision-log.md#d31-the-release-creates-a-missing-target-at-the-version-it-is-publishing)). **The -release creates what it can derive and refuses what must be authored.** A plugin's storefront presence — its names, its -descriptions, the examples of what to ask it — is written by a person, and a release that invented it would be publishing -prose nobody wrote to a storefront people read. A plugin with no authored presence on channel two is therefore a gap the -release names and stops on, exactly like a listing entry with nothing behind it +A created entry, and any record the release updates, carries the version the release is publishing for that plugin — +which for a plugin the release did not bump is the version it already has, read from that plugin's own channel-one record +and never from a listing, since a listing is one of the things this rule exists to correct and cannot also be the +authority +([D31](artifacts/decision-log.md#d31-the-release-creates-a-missing-target-at-the-version-it-is-publishing)). + +**A release refuses to proceed when it finds a gap it cannot close.** This is the list, stated once; everywhere else in +this specification that names a gap is naming one of these: + +- a plugin with no record on a channel it belongs in — the presence is unwritten, and only a person writes it; +- a listing naming a plugin that is not in the repository; +- a record or a listing it cannot read; +- a version value it cannot make sense of; +- a plugin whose publishing version it cannot determine. + +Every one of them is a person's decision wearing a release's clothes, which is why the release hands them back rather +than guessing ([D36](artifacts/decision-log.md#d36-a-release-creates-what-it-can-derive-and-stops-at-what-must-be-authored)). -A release refuses to proceed when it finds a gap it cannot close: a listing naming a plugin that does not exist, a -record it cannot read, a version it cannot make sense of, a plugin whose publishing version it cannot determine, or a -plugin whose presence on a channel it would have to author. **The gate runs on the state being released — after the -release has brought all four targets up to date, and before it commits, tags, pushes, or publishes anything.** Early -enough that every action after it is still local and reversible; late enough to judge what is actually being released -rather than what preceded it +**The gate runs on the state being released — after the release has brought all four targets up to date, and before it +commits, tags, pushes, or publishes anything.** Early enough that every action after it is still local and reversible; +late enough to judge what is actually being released rather than what preceded it ([D24](artifacts/decision-log.md#d24-the-gate-runs-after-all-targets-are-written-and-before-anything-irreversible)). When it stops, it names every gap it found rather than the first ([D12](artifacts/decision-log.md#d12-a-missing-plugin-stops-the-release-and-every-gap-is-named)). @@ -276,11 +306,22 @@ Because the release process is repaired first, this correction is durable: the v than re-freezing it. Had this step come first, any release cut before the repair would have undone it ([D18](artifacts/decision-log.md#d18-the-execution-order-is-a-partial-order-and-the-repair-precedes-the-correction)). -This step is worth doing by hand even though step 3 makes a release do it. Once the repair lands, the first release -corrects these numbers whether or not this step ever runs — so what this step buys is that the numbers are right on -merge rather than whenever someone next cuts a release. That is a smaller claim than "the correction requires this -step", and it is the true one -([D38](artifacts/decision-log.md#d38-the-repair-and-the-correction-are-ordered-not-united)). +This step is worth doing by hand even though step 3 makes a release do it, and it is worth being precise about why, +because the obvious reason is the weaker one. + +The obvious reason is that the numbers are right on merge rather than whenever someone next cuts a release. That is a +smaller claim than "the correction requires this step", and it is **contingent on the same unknown step 1 is hedged +against**: if channel two's client resolves from the release tag rather than the default branch, then the only revision +anyone resolves is one a release produced, and a release corrects these numbers itself. Under tag resolution this step's +user-facing benefit is not smaller, it is nothing — what survives is that the repository stops publishing numbers it +knows are wrong ([Open item 2](#open-items)). + +The reason that does not depend on that unknown: **step 4 is what makes step 7's check green on the day it arrives.** +Without it, and absent a release cut somewhere between step 3 and step 7, the check lands red against eight +disagreements — a signal that is red from birth is one people learn to scroll past, which is the failure D1 sequences the +check last to avoid +([D1](artifacts/decision-log.md#d1-the-check-lands-last-because-a-check-that-blocks-everything-gets-disabled), +[D38](artifacts/decision-log.md#d38-the-repair-and-the-correction-are-ordered-not-united)). This step closes eight gaps. Eight plugins carry a channel-two version that has fallen behind, and the Linear plugin's record arrives agreeing because step 1 created it at the version channel one already publishes. A ninth gap opens only @@ -441,23 +482,25 @@ The failure mode there is quieter and is the one that is actually reachable: a c people learn to scroll past, and a signal nobody reads protects nothing ([D1](artifacts/decision-log.md#d1-the-check-lands-last-because-a-check-that-blocks-everything-gets-disabled)). -**A release runs while a plugin is missing from a channel-two target.** The release creates the missing record at the -version it is publishing for that plugin, reports that it did, and proceeds +**A release runs while a plugin is missing from channel two's listing.** The release creates the entry at the version it +is publishing for that plugin, reports that it did, and proceeds ([D31](artifacts/decision-log.md#d31-the-release-creates-a-missing-target-at-the-version-it-is-publishing), -[D39](artifacts/decision-log.md#d39-a-release-reports-what-it-created)). This is the ordinary path, not the failure -path: the whole point of the repair is that a release closes this gap rather than reporting it. A plugin missing from a -**channel-one** target is the other case, and it is a stop rather than a repair -([D36](artifacts/decision-log.md#d36-a-release-creates-what-it-can-derive-and-stops-at-what-must-be-authored)). +[D39](artifacts/decision-log.md#d39-a-release-reports-what-it-created)). This is the ordinary path, and it is the only +membership gap a release closes. + +**A release runs while a plugin has no channel-two record.** The release stops. The record is the plugin's authored +presence, so there is nothing to derive and a release that wrote one would be inventing it. This is the Linear plugin's +shape today, and it is why step 1 is a person's work and why step 1 comes before step 3 +([D36](artifacts/decision-log.md#d36-a-release-creates-what-it-can-derive-and-stops-at-what-must-be-authored), +[D41](artifacts/decision-log.md#d41-step-1-precedes-the-release-repair-because-an-unauthored-presence-stops-a-release)). **A release runs while a plugin's version has fallen behind on a target.** The release writes the version it is publishing for that plugin onto the record and proceeds, whether or not it bumped that plugin this release. This is also the ordinary path, and it is what makes step 4's correction durable rather than a thing that re-freezes ([D38](artifacts/decision-log.md#d38-the-repair-and-the-correction-are-ordered-not-united)). -**A release meets a gap it cannot close.** A listing naming a plugin that is not in the repository, a record it cannot -read, a version value it cannot make sense of, a plugin whose publishing version it cannot determine, or a plugin whose -presence on a channel would have to be authored. The release stops after bringing the targets up to date and before -committing +**A release meets a gap it cannot close.** The list is in step 3 and is not repeated here. The release stops after +bringing the targets up to date and before committing ([D24](artifacts/decision-log.md#d24-the-gate-runs-after-all-targets-are-written-and-before-anything-irreversible)), naming every gap it found rather than the first ([D12](artifacts/decision-log.md#d12-a-missing-plugin-stops-the-release-and-every-gap-is-named)). Nothing is tagged, @@ -482,10 +525,13 @@ when the correction reaches the branch releases are cut from, not when the relea ([D34](artifacts/decision-log.md#d34-a-gate-stop-costs-a-separate-commit-because-the-release-refuses-a-dirty-tree)). **A release fails partway through writing the four targets.** Every write happens before anything irreversible, so the -repository is left with local modifications and nothing published. Recovery here *is* discarding local changes and -re-running, because nothing in the repository is wrong — only the incomplete local work. No compensation or rollback -machinery is specified because none is needed -([D24](artifacts/decision-log.md#d24-the-gate-runs-after-all-targets-are-written-and-before-anything-irreversible)). +repository is left with the release's local work and nothing published. Recovery here *is* discarding that work and +re-running, because nothing in the repository is wrong — only the incomplete local work. Discarding it means discarding +**everything the release wrote and everything it created**, for the same reason a gate stop does: a created file is +untracked, survives a cleanup aimed at modified files, and leaves the tree dirty enough that the next release refuses to +start. No compensation or rollback machinery is specified because none is needed +([D24](artifacts/decision-log.md#d24-the-gate-runs-after-all-targets-are-written-and-before-anything-irreversible), +[D34](artifacts/decision-log.md#d34-a-gate-stop-costs-a-separate-commit-because-the-release-refuses-a-dirty-tree)). **A work-items run meets a file annotated by a different tracker.** The run stops before creating anything and names the work items whose annotations it does not recognize, so a person decides whether the file was already published elsewhere @@ -496,48 +542,72 @@ as already published, skipped, and reported in the skipped count. A partial run **A new plugin is added after this work.** The check fails visibly on the pull request that adds it, telling the contributor which targets it is missing from. If they act on it, the plugin arrives complete. If they merge past it — -which they can — the next release creates the missing records itself -([D31](artifacts/decision-log.md#d31-the-release-creates-a-missing-target-at-the-version-it-is-publishing)), so the -signal being advisory costs lateness rather than correctness. The signal is only fair because step 3 corrected the -contributor guide to name all four targets rather than one +which they can — what it costs depends on what they left out, and the spec no longer flattens the two +([D32](artifacts/decision-log.md#d32-the-guarantee-is-stated-per-surface-because-only-the-release-can-refuse)). + +A missing **listing entry** costs lateness: the next release creates it +([D31](artifacts/decision-log.md#d31-the-release-creates-a-missing-target-at-the-version-it-is-publishing)). A missing +**channel-two record** costs a stopped release: nobody has written the plugin's presence, so the next release refuses +until someone does. That is the more likely omission, because a brand-new plugin has no presence written by definition — +so the honest reading is that merging past this signal can stop the next release rather than merely delay a fix +([D36](artifacts/decision-log.md#d36-a-release-creates-what-it-can-derive-and-stops-at-what-must-be-authored)). + +That is a real cost and arguably the right one: a plugin should not reach a storefront before a person has written what +it says. But it means the advisory check is tolerable for a different reason than "the release will fix it anyway" — it +is tolerable because the release refuses to publish something nobody wrote, which is a protection rather than a repair. +The signal is only fair because step 3 corrected the contributor guide to name all four targets rather than one ([D21](artifacts/decision-log.md#d21-the-release-procedure-documents-are-owned-by-the-step-that-falsifies-them)). **A release is cut from a branch where a step has not landed.** Mostly nothing stops it, and the specification says so rather than claiming a backstop it does not have. A branch missing step 3 carries the old release, which has no gate at -all and publishes channel one exactly as it does today. A branch missing steps 1 or 4 is repaired by the release rather -than refused, which is the repair working. A branch missing steps 5, 6, or 7 releases cleanly, because the gate does not +all and publishes channel one exactly as it does today. A branch missing step 4 is repaired by the release rather than +refused, which is the repair working. A branch missing steps 5, 6, or 7 releases cleanly, because the gate does not inspect dependency declarations, documents, or its own existence. The release process permits cutting from a non-default branch, and a branch with no pull request gets no pipeline run, so there is no second surface either ([D32](artifacts/decision-log.md#d32-the-guarantee-is-stated-per-surface-because-only-the-release-can-refuse)). The gate's job is the gaps a release cannot close, not the steps of this plan ([D38](artifacts/decision-log.md#d38-the-repair-and-the-correction-are-ordered-not-united)). -**A plugin is being removed from the suite.** A removal that lands whole — the directory and both channels' entries -gone together — is not a state the rule ever sees. A removal that lands half-finished is, and it is the same four-file -mistake this work exists to prevent, pointed the other way. The rule does not distinguish "never published here" from -"published here until recently", so a release meeting a half-removed plugin would helpfully create back the records the -removal deleted. **A plugin the repository still carries is a plugin the rule expects in every target it belongs in**, -which means a removal is finished or it is not: a directory that remains is a plugin, and its absence from a target it -belongs in is a gap the release closes. Removing a plugin means removing the directory +**A branch missing step 1 is the exception, and it is the reason step 1 is a binding constraint.** The Linear plugin's +presence is unwritten there, so the release refuses rather than repairs — the one case where a missing step of this plan +does stop a release, and it stops it for the right reason rather than as a backstop +([D41](artifacts/decision-log.md#d41-step-1-precedes-the-release-repair-because-an-unauthored-presence-stops-a-release)). + +**A plugin is being removed from the suite.** A removal that lands whole — the directory and both channels' entries gone +together — is not a state the rule ever sees. A removal that lands half-finished is, and it is the same four-file mistake +this work exists to prevent, pointed the other way. The rule does not distinguish "never published here" from "published +here until recently", and it does not try: inferring intent from absence is exactly the inference that cannot be made, +since the two are the same state on disk. + +**A plugin the repository still carries is a plugin the rule expects in every target it belongs in.** A removal is +finished or it is not, and a directory that remains is a plugin. Removing a plugin means removing the directory ([D40](artifacts/decision-log.md#d40-a-half-finished-removal-is-not-a-state-the-release-guesses-at)). +What the release then does about the leftovers is not one answer, and the same boundary decides it. A half-removal that +deleted the plugin's channel-two listing entry is repaired — the release puts it back, which is the honest consequence of +the directory still being there. A half-removal that deleted a **record** is a stop, on either channel, because what was +deleted is the thing only a person writes. So a half-finished removal mostly does not get silently undone; it gets named, +and the person finishes the job in one direction or the other +([D36](artifacts/decision-log.md#d36-a-release-creates-what-it-can-derive-and-stops-at-what-must-be-authored), +[D40](artifacts/decision-log.md#d40-a-half-finished-removal-is-not-a-state-the-release-guesses-at)). + ## Edge cases and failure modes | Case | Behavior | | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | | The bundle is absent from channel two | Not flagged, **not created**, and the cross-channel comparison does not apply to it. The creation verb matters as much as the other two: it is the one plugin a repairing release would publish to a channel that cannot install it ([D6](artifacts/decision-log.md#d6-the-bundle-is-a-permanently-named-exception-on-the-second-channel)) | -| The bundle's two channel-one records disagree with each other | Check fails, like any other plugin. The bundle's exemption is from the comparison between channels, not from agreement within one. It bumps every release and its version names the release tag, so this is the most-exercised pair in the suite ([D6](artifacts/decision-log.md#d6-the-bundle-is-a-permanently-named-exception-on-the-second-channel), [D20](artifacts/decision-log.md#d20-version-agreement-covers-every-record-that-publishes-a-version)) | -| A plugin is missing from a **channel-two** target it belongs in | A release creates the missing record, reports that it did, and proceeds ([D31](artifacts/decision-log.md#d31-the-release-creates-a-missing-target-at-the-version-it-is-publishing), [D39](artifacts/decision-log.md#d39-a-release-reports-what-it-created)). On a pull request the check reports it, naming the plugin and every target it is missing from. This is the shape of the defect that motivated the work ([D19](artifacts/decision-log.md#d19-a-plugin-and-the-targets-it-belongs-in-are-defined-positively)) | -| A plugin is missing from a **channel-one** target it belongs in | Check fails; the release stops rather than creating it. Creation is committed where the evidence is, and no plugin has ever been missing from a channel-one target — carrying a channel-one record is part of what makes a directory a plugin, and creating a channel-one listing entry would mean authoring the description that entry carries ([D36](artifacts/decision-log.md#d36-a-release-creates-what-it-can-derive-and-stops-at-what-must-be-authored)) | -| A plugin has no authored presence on a channel it belongs in | Check fails; the release stops rather than inventing one. A storefront presence is prose a person wrote, and a release that composed it would be publishing writing nobody authored to a page people read ([D36](artifacts/decision-log.md#d36-a-release-creates-what-it-can-derive-and-stops-at-what-must-be-authored)) | +| The bundle's two channel-one records disagree with each other | Reported on a pull request and repaired on a release, like any other plugin. The bundle's exemption is from channel two, not from agreement within channel one. It bumps every release and its version names the release tag, so this is the most-exercised pair in the suite — and the repair now keeps it in agreement rather than the gate merely watching it, which closes the drift this row was written to catch ([D6](artifacts/decision-log.md#d6-the-bundle-is-a-permanently-named-exception-on-the-second-channel), [D20](artifacts/decision-log.md#d20-version-agreement-covers-every-record-that-publishes-a-version), [D38](artifacts/decision-log.md#d38-the-repair-and-the-correction-are-ordered-not-united)) | +| A plugin is missing from **channel two's listing** | A release creates the entry, reports that it did, and proceeds ([D31](artifacts/decision-log.md#d31-the-release-creates-a-missing-target-at-the-version-it-is-publishing), [D39](artifacts/decision-log.md#d39-a-release-reports-what-it-created)). On a pull request the check reports it. This is the only membership gap a release closes, because an entry is derivable and a record is not ([D36](artifacts/decision-log.md#d36-a-release-creates-what-it-can-derive-and-stops-at-what-must-be-authored)) | +| A plugin has **no record** on a channel it belongs in | Check fails; the release stops rather than inventing one. The record is the plugin's authored presence, so there is nothing to derive. This is the Linear plugin's shape today, and it is why step 1 is a person's work and why step 1 precedes step 3 ([D36](artifacts/decision-log.md#d36-a-release-creates-what-it-can-derive-and-stops-at-what-must-be-authored), [D41](artifacts/decision-log.md#d41-step-1-precedes-the-release-repair-because-an-unauthored-presence-stops-a-release)) | +| A plugin is missing from **channel one's listing** | Check fails; the release stops rather than creating it. The entry carries an authored description, and no plugin has been missing from it — so this is the expensive half of creation serving a case with no members ([D36](artifacts/decision-log.md#d36-a-release-creates-what-it-can-derive-and-stops-at-what-must-be-authored)) | | A plugin is in a storefront listing but not in the repository | Check fails. A listing entry resolving to nothing breaks the install-succeeds promise directly, and it is the one membership gap a release must not "fix" by itself, since the remedy is a person deciding whether the plugin or the entry is the mistake ([D29](artifacts/decision-log.md#d29-a-listing-entry-with-no-plugin-behind-it-fails-the-check)) | -| A plugin's version records disagree | On a pull request the check fails, naming the plugin and every disagreeing record. On a release it is repaired rather than reported: the release writes the publishing version to every record it can, so a disagreement it caused is the only one the gate can still see ([D20](artifacts/decision-log.md#d20-version-agreement-covers-every-record-that-publishes-a-version), [D38](artifacts/decision-log.md#d38-the-repair-and-the-correction-are-ordered-not-united)) | +| A plugin's version records disagree | On a pull request the check fails, naming the plugin and every disagreeing record. On a release it is repaired rather than reported: the release writes the publishing version to every record it can, so the gate never meets this class after a repairing release ([D20](artifacts/decision-log.md#d20-version-agreement-covers-every-record-that-publishes-a-version), [D38](artifacts/decision-log.md#d38-the-repair-and-the-correction-are-ordered-not-united)) | | A plugin's publishing version cannot be determined | Check fails; the release stops rather than guessing. A plugin the release cannot assign a version to is a gap creation cannot close, alongside a dangling listing entry and an unreadable record ([D36](artifacts/decision-log.md#d36-a-release-creates-what-it-can-derive-and-stops-at-what-must-be-authored)) | | A storefront listing cannot be read at all | Surfaced and blocking for that whole channel, never read as "every plugin is missing from this target". A listing is one shared file covering every plugin, so a parse failure would otherwise route the entire channel into the create-path and overwrite the file with regenerated entries — the loudest possible version of the silent defect this work exists to end ([D35](artifacts/decision-log.md#d35-an-unreadable-record-or-version-is-surfaced-not-skipped)) | | A record cannot be read at all | Surfaced and blocking, never skipped. A record that fails to parse must not silently drop its plugin from the set being checked — a rule applied to a set that quietly excludes the broken member is the same invisible-by-construction defect this work exists to end ([D35](artifacts/decision-log.md#d35-an-unreadable-record-or-version-is-surfaced-not-skipped)) | -| A record publishes a version, but the value is absent, empty, or not a version | Check fails, naming it, exactly as a disagreement does. Two unreadable values are never treated as agreeing with each other ([D35](artifacts/decision-log.md#d35-an-unreadable-record-or-version-is-surfaced-not-skipped)) | +| A record publishes a version, but the value is absent, empty, or not a version | On a pull request the check fails, naming it — two unreadable values are never treated as agreeing with each other. On a release the record is overwritten with the publishing version like any other stale record, because it is the same act ([D35](artifacts/decision-log.md#d35-an-unreadable-record-or-version-is-surfaced-not-skipped), [D38](artifacts/decision-log.md#d38-the-repair-and-the-correction-are-ordered-not-united)). The exception is a record the release cannot read at all, which is the row above: a broken value inside a readable record is repairable, and a record it cannot parse is not | | A plugin has a manifest and no skills | Valid. The bundle is permanently in this state, so "has skills" is not part of what makes a directory a plugin ([D19](artifacts/decision-log.md#d19-a-plugin-and-the-targets-it-belongs-in-are-defined-positively)) | -| A plugin's directory remains but its records were deleted | Treated as a plugin missing from those targets, not as a removal in progress. The rule does not guess intent from absence, so a removal is finished or it is not ([D40](artifacts/decision-log.md#d40-a-half-finished-removal-is-not-a-state-the-release-guesses-at)) | +| A plugin's directory remains but its records were deleted | Treated as a plugin missing from those targets, not as a removal in progress — the rule does not guess intent from absence, so a removal is finished or it is not. A deleted listing entry is put back; a deleted record is a stop, because what was deleted is the thing only a person writes. So a half-removal is named rather than silently undone ([D36](artifacts/decision-log.md#d36-a-release-creates-what-it-can-derive-and-stops-at-what-must-be-authored), [D40](artifacts/decision-log.md#d40-a-half-finished-removal-is-not-a-state-the-release-guesses-at)) | | A work item's heading is malformed in any way the publisher does not recognize | Surfaced, never silently passed over. "Accounted for" means every heading is either published, skipped-and-counted, or surfaced ([D30](artifacts/decision-log.md#d30-accounted-for-is-defined-so-the-promise-is-not-circular)) | | A work-items file mixes annotated and unannotated items for the same tracker | Unchanged: annotated items skipped and counted, unannotated items published | | Two trackers' annotations are indistinguishable from each other | Out of scope here; the trap remains and is specified separately ([D2](artifacts/decision-log.md#d2-step-2-closes-the-silent-hole-only-annotation-namespacing-is-separate)) | @@ -549,7 +619,7 @@ belongs in is a gap the release closes. Removing a plugin means removing the dir a target it also writes, so a stale listing can no longer hide a plugin from it ([D5](artifacts/decision-log.md#d5-the-check-derives-the-plugin-list-from-the-repository)). - **The release process and both storefronts.** The release brings all four targets up to date — updating a version that - has fallen behind, and creating a channel-two record that does not exist — before it does anything irreversible + has fallen behind, and creating a channel-two listing entry that does not exist — before it does anything irreversible ([D24](artifacts/decision-log.md#d24-the-gate-runs-after-all-targets-are-written-and-before-anything-irreversible), [D31](artifacts/decision-log.md#d31-the-release-creates-a-missing-target-at-the-version-it-is-publishing), [D36](artifacts/decision-log.md#d36-a-release-creates-what-it-can-derive-and-stops-at-what-must-be-authored)). @@ -595,9 +665,11 @@ The people who experience this feature are maintainers and contributors at a ter the same; the consequence is not. On a pull request it informs, and the contributor may proceed anyway. On a release it refuses — over a smaller set, because a release repairs on its way to the gate and a pull request has nothing that repairs ([D38](artifacts/decision-log.md#d38-the-repair-and-the-correction-are-ordered-not-united)). -- **Release repair.** Names what it created and what it corrected — which plugin, which targets, at what version — - alongside the version plan it already reports. A release that quietly starts writing to what Han publishes is the same - shape as a release that quietly stopped, which is the defect being repaired +- **Release repair.** Names what it created and what it **changed** — which plugin, which targets, at what version — + alongside the version plan it already reports. Changed, not written: after the repair a release writes every record on + every run, so a report of everything written would name every plugin and every target forever, which is a report + nobody reads and the same failure by a different route. A release that quietly starts writing to what Han publishes is + the same shape as a release that quietly stopped, which is the defect being repaired ([D39](artifacts/decision-log.md#d39-a-release-reports-what-it-created)). - **Release stop.** Names every gap it found before stopping, not just the first one, so a maintainer learns the full set in one run ([D12](artifacts/decision-log.md#d12-a-missing-plugin-stops-the-release-and-every-gap-is-named)). The stop @@ -670,11 +742,18 @@ The people who experience this feature are maintainers and contributors at a ter **Reopening trigger:** someone publishes from the wrong file, or a run of zeroes is mistaken for a run that worked. - **Confirming a plugin's first publication to a channel before a release makes it installable.** Creation makes a directory publicly installable with no sign-off, which is a larger act than the version bump that does get confirmed. - It is deferred because the release now reports what it created - ([D39](artifacts/decision-log.md#d39-a-release-reports-what-it-created)), which is the strictly simpler thing that - satisfies the same concern, and because the case it guards against — a half-built plugin merged to the default branch - and released before anyone noticed — has never happened. **Reopening trigger:** a release publishes a plugin that was - not ready, or a maintainer asks to hold a plugin's directory on the default branch while keeping it unpublished. + It is deferred because the case it guards against — a half-built plugin merged to the default branch and released + before anyone noticed — has never happened, and because a plugin cannot reach a storefront without a person having + written its presence first, which is a sign-off of a kind + ([D36](artifacts/decision-log.md#d36-a-release-creates-what-it-can-derive-and-stops-at-what-must-be-authored)). + + It is **not** deferred on the grounds that reporting satisfies the same concern. Reporting is notice, not sign-off, and + on the ordinary path the notice is not separated from the publish by any prompt: the release prints what it did and + continues, because the prompt that would stop it is off by default and the confirmation that always runs happens before + the writes ([D39](artifacts/decision-log.md#d39-a-release-reports-what-it-created)). A maintainer learns what was + created in the same output as the push. That is worth having and it is not a gate. **Reopening trigger:** a release + publishes a plugin that was not ready, or a maintainer asks to hold a plugin's directory on the default branch while + keeping it unpublished. ## Open items @@ -712,8 +791,9 @@ The people who experience this feature are maintainers and contributors at a ter - **Outcome.** Han publishes completely and honestly to both channels, and no release can quietly stop it being so. - **Actors.** Han maintainers, contributors, and channel-two installers. -- **Scope.** Seven steps. The check is last; the release repair precedes the version correction; the declaration - deletion and document correction are one unit shipped in a single change; the work-items fix is independent. +- **Scope.** Seven steps. The check is last; step 1 precedes the release repair; the release repair precedes the version + correction; the declaration deletion and document correction are one unit shipped in a single change; the work-items + fix is independent. - **Decisions.** See [decision-log.md](artifacts/decision-log.md). - **Technical notes.** See [feature-technical-notes.md](artifacts/feature-technical-notes.md). - **Sub-agents.** Planning: junior-developer, devops-engineer, edge-case-explorer, information-architect — see @@ -735,6 +815,14 @@ The people who experience this feature are maintainers and contributors at a ter carry authored prose rather than just a version. The release now commits every target it writes, without which the tag would have named a state the gate never saw. The bundle's exception gained the verb "create". The gate's promise to refuse before anyone approves was dropped as unmeetable. +- **Key adjustments from the third review round.** One decision was found to contradict itself, and everything else + followed from it. A release cannot create a plugin's storefront record, because that record **is** the storefront + presence and a release would be inventing prose rather than deriving a version. So creation reaches channel two's + listing entry and nothing else, and the Linear plugin — the case this work exists for — is closed by a person at step + 1, exactly as step 1 always said and step 3 denied. Two consequences the spec had backwards are now stated: step 1 is a + binding constraint before step 3, because a release that meets an unwritten presence refuses rather than repairs; and a + new plugin merged past the check stops the next release rather than costing lateness, which is a better bargain than + the one the spec was claiming but a different one. - **Deferred under YAGNI.** 6. - **Open items.** 4, of which two are unverified external behaviors, one is a settled fact carrying an unmade decision, and one is a real question the team owes itself. @@ -743,9 +831,13 @@ The people who experience this feature are maintainers and contributors at a ter - **Review mode:** team. - **Spec-aware mode:** engaged. -- **Rounds completed:** 2 of a 3-round cap — see - [artifacts/review-iteration-history.md](artifacts/review-iteration-history.md). **Not stable; a third round is - recommended, scoped to the decisions round 2 added.** +- **Rounds completed:** 3 of a 3-round cap — the cap is reached, so this review stops here. See + [artifacts/review-iteration-history.md](artifacts/review-iteration-history.md). **Not stable at the cap.** Every round + found real defects, and each round's defects were narrower than the last: round 1 falsified claims about the world, + rounds 2 and 3 falsified none and instead found one decision each that had not been read against its neighbours. The + factual foundation has never failed re-verification. The recommended next move is not another full review but a + targeted check of round 3's own output — D36-as-corrected and D41 against D31, D38, D39, and D40 — because that is the + check this document has now failed three times running, and it costs one pass rather than one round. - **Team composition:** - `junior-developer` — required; reframes the spec in plain terms and surfaces hidden assumptions and standards conflicts. @@ -756,9 +848,9 @@ The people who experience this feature are maintainers and contributors at a ter - `devops-engineer` — the release gate, the enforcement surface, and the rollout ordering are the spec's spine. - `edge-case-explorer` — the failure-mode table and the stop-before-create gate carry the spec's silent-failure commitments. -- **Findings raised:** 37 across both rounds — see [artifacts/review-findings.md](artifacts/review-findings.md). Round 1: - 14 major, 2 minor. Round 2: 17 major, 4 minor, plus 2 raised and rejected. By resolution source: 7 by user input on - surfaced trade-offs, 30 by evidence. +- **Findings raised:** 52 across three rounds — see [artifacts/review-findings.md](artifacts/review-findings.md). Round + 1: 14 major, 2 minor. Round 2: 17 major, 4 minor, plus 2 rejected. Round 3: 10 major, 3 minor, plus 2 rejected. By + resolution source: 8 by user input on surfaced trade-offs, 44 by evidence. - **YAGNI candidates:** 1 raised as `Category: YAGNI candidate`, in round 2, and resolved by the replace-with-simpler-version path: creation was committed on all four targets on evidence that exists for two, and is now scoped to the two where the incident lives @@ -772,6 +864,14 @@ The people who experience this feature are maintainers and contributors at a ter ([F48](artifacts/review-findings.md#f48-d31s-no-plugin-for-which-the-phrase-is-undefined-is-false-for-the-shape-d19-deliberately-admits)) and an apparent-removal detector that would infer intent from absence ([F58](artifacts/review-findings.md#f58-a-half-finished-removal-would-be-silently-undone-by-a-repairing-release)). + Round 3 raised no new candidates and added no deferrals: an adversarial YAGNI sweep over all five of round 2's + decisions and both of its deferrals found no unevidenced scope creep, and the round's defects were correctness + failures in decisions that were properly evidenced for inclusion. Its closest call is recorded rather than resolved + silently — the surviving creation capability has no live instance, which by + [F47](artifacts/review-findings.md#f47-creation-is-committed-on-all-four-targets-on-evidence-that-exists-for-two)'s own + test would cut it; it is kept because a listing entry is derivable, and the spec now says that instead of citing an + incident it cannot close + ([F81](artifacts/review-findings.md#minor-edits)). - **Assumptions challenged across all passes:** that a red pull-request check blocks a merge (false — nothing here makes it blocking); that the rule only starts refusing at step 7 (false — it refuses from step 3, because the release runs it); that the repaired release could fix what it found (false — it could only report); that two plugins carried untrue @@ -780,20 +880,33 @@ The people who experience this feature are maintainers and contributors at a ter releases between steps 3 and 4 (false — a repairing release closes the disagreements it would have stopped on); that a version record states a version (false — it carries the plugin's authored storefront presence, and a channel-two listing entry carries the policy deciding installability); that the release publishes what it writes (false — its - commit reaches two of four targets); and that a plugin's publishing version is always defined (false for the - channel-two-only shape the spec deliberately admits). + commit reaches two of four targets); that a plugin's publishing version is always defined (false for the + channel-two-only shape the spec deliberately admits); that a release can create the record whose absence motivated + this work (false — that record **is** the authored presence, so the Linear plugin is closed by a person at step 1, not + by a release); that a contributor merging past the check costs only lateness (false — a new plugin has no written + presence by definition, so it stops the next release); and that no step of this plan can freeze a release (false — + step 3 without step 1 stops every release, which is a real version of the freeze round 2 correctly refuted for version + drift). - **Consolidations made:** none survive. Round 1 merged steps 3 and 4 into one unit; round 2 found the freeze that justified it does not occur and reverted them to the ordering the decision log had recorded all along. Steps 5 and 6 remain the one genuine unit, now stated as a single change rather than as two that follow closely. The check and the release keep one bearer, and the spec now states both what that bearer can enforce on each surface and why the two surfaces answer differently. - **Ambiguities resolved, and how:** what version a created record carries (D31, narrowed in round 2 to name the - authoritative source and to stop claiming totality); what a created record actually contains (D36, after the record - turned out to carry authored prose); which documents state the rule versus keep their enumeration (D26, scoped to the - sentence shape it was reasoned from); whether an already-false neighbor is corrected (D33, bounded to the paragraph - after its stated rule and its worked example were found to disagree); what happens to a record that cannot be read - (D35, extended to the shared listing shape); what the release does with what it wrote (D37); what a gate stop leaves - behind (D34, completed); and what a half-finished removal means (D40). + authoritative source and to stop claiming totality); **what a release can create at all** (D36, corrected in round 3 + after the decision was found to contradict itself — the boundary is derivable-versus-authored, and it puts the listing + entry on one side and every record on the other); which documents state the rule versus keep their enumeration (D26, + scoped to the sentence shape it was reasoned from); whether an already-false neighbor is corrected (D33, bounded to the + paragraph after its stated rule and its worked example were found to disagree); what happens to a record that cannot be + read (D35, extended to the shared listing shape); what the release does with what it wrote (D37); what a gate stop + leaves behind (D34, completed); what a half-finished removal means (D40, its mechanism corrected in round 3 to follow + D36); and which step orderings actually bind (D41, after round 3 found step 1 had become binding without being named). +- **Where the review kept finding the same defect:** three rounds running, in a different decision each time. A decision + gets made late in a round, and nobody reads it against the decisions it changed. Round 1 did it with the release-freeze + claim and the creation capability; round 2 did it inside D36, the decision it wrote to fix round 1's version; round 3's + own output is the next candidate. The check that caught it twice is mechanical and cheap — a decision's + dependent-decisions list not naming the decision that contradicts it — and it is the first thing a further pass should + run. - **Technical notes added/edited:** 1 added, in round 1 — see [artifacts/feature-technical-notes.md](artifacts/feature-technical-notes.md). Round 2 added none and required none; T2 was re-verified against the live platform and is unchanged. From 1334b8d16fab10a319d0e951a868283b52a3d0d3 Mon Sep 17 00:00:00 2001 From: River Lynn Bailey <river.bailey@testdouble.com> Date: Fri, 17 Jul 2026 07:07:07 -0600 Subject: [PATCH 06/22] docs(plans): start han-publishing-cleanup build phase outline Executive summary, phase index, and vocabulary grounding. Six phases, with the source spec's steps 5 and 6 merged into one because the spec binds them to a single change. --- .../build-phase-outline.md | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 docs/plans/han-publishing-cleanup/build-phase-outline.md diff --git a/docs/plans/han-publishing-cleanup/build-phase-outline.md b/docs/plans/han-publishing-cleanup/build-phase-outline.md new file mode 100644 index 00000000..0b3fe1d7 --- /dev/null +++ b/docs/plans/han-publishing-cleanup/build-phase-outline.md @@ -0,0 +1,132 @@ +--- +title: "Han Publishing Cleanup — Build Phase Outline" +source_artifact: "feature-specification.md" +audience: "Engineering — Han maintainers and contributors" +generated: "2026-07-17" +generated_by: "han-planning:plan-a-phased-build" +--- + +# Han Publishing Cleanup — Build Phase Outline + +This document describes the order in which the Han publishing cleanup will be built. The work is broken into a sequence +of **phases**, where each phase is a thin end-to-end deliverable that can be demonstrated to a real person, and each +phase builds on the one before it. The cleanup repairs how Han is published to the two places it ships to, so that +everything advertised as installable actually installs, everyone who installed it is offered updates again, and no +future release can quietly stop either being true. + +This document is the companion to [feature-specification.md](feature-specification.md). The source artifact describes +_what is broken today, what each of its seven steps changes, and why they are ordered the way they are_. This document +describes _the order in which the work will be built, and what you can put in front of a person at the end of each +phase_. Every phase below cites the source-artifact sections it covers, so anyone can trace a phase back to source. + +## Table of Contents + +- [Vocabulary](#vocabulary) +- [Executive Summary](#executive-summary) +- [Build Phase Index](#build-phase-index) +- [Phase Kinds](#phase-kinds) +- [Build Phases](#build-phases) + - [Phase 1: Make the Linear plugin installable on channel two](#phase-1) + - [Phase 2: Stop the work-items publisher dropping work silently](#phase-2) + - [Phase 3: Make a release bring every publishing target up to date](#phase-3) + - [Phase 4: Unfreeze channel two's version numbers](#phase-4) + - [Phase 5: Delete the untrue dependencies and correct what described them](#phase-5) + - [Phase 6: Show the gap on the pull request that introduces it](#phase-6) +- [Open Questions](#open-questions) + +--- + +## Vocabulary {#vocabulary} + +The source artifact stays deliberately neutral about which storefront is which, and this document inherits that. Four +words carry the whole outline. + +- **Channel one** and **channel two** — the two places Han is published. Channel one is healthy; nothing here changes + what it publishes. Channel two is the one that has been rotting. +- **A storefront listing** — the per-channel catalogue saying which plugins exist. One per channel. +- **A version record** — a place that states a plugin's published version. There are three: each channel carries one per + plugin, and channel one's listing also carries a version per plugin. +- **A target** — any of the four places a release must keep current: the two storefront listings and the two per-plugin + records. +- **The bundle** — the meta-plugin that installs the suite in one command. It exists on channel one only. + +The full grounding, including what each target carries besides a version, is in the source artifact's +[Channels and targets](feature-specification.md#channels-and-targets). + +--- + +## Executive Summary {#executive-summary} + +**The goal:** Han is published completely and honestly to both of the places it ships to, and no release can quietly +stop it being so. Concretely: every plugin advertised as installable on a channel actually installs there, people who +installed Han on channel two are offered updates again, every dependency a plugin declares is one it really uses, every +document describing the suite describes the suite that exists, and every release brings all four publishing targets up +to date rather than two of them. + +**The shape of the build:** + +- **Phases 1 and 2 fix two live, independent breakages** — a documented install command that errors today, and a + work-items publisher that drops work with no signal. Neither waits on anything. +- **Phase 3 is the hinge.** It teaches the release to derive its plugin list from the repository rather than from a file + it also writes, to bring all four targets up to date, and to refuse rather than guess when it meets a gap only a person + can close. Everything after it is either made durable by it or made honest by it. +- **Phase 4 unfreezes the version numbers by hand**, and it is durable only because phase 3 already landed. A release cut + before phase 3 would have re-frozen them. +- **Phase 5 removes three dependency declarations that were never true, and corrects every document that narrated them, + in a single change.** Shipping half of that pair is the exact state the phase exists to prevent. +- **Phase 6 puts the answer in front of a contributor** on the pull request that introduces a gap, rather than in front + of a maintainer on release day. It lands last, and it lands green. + +**Sequencing rationale:** Four constraints bind, and the rest of the order is free. The check goes last, because a +signal that is red from birth is a signal people learn to scroll past. Phase 1 precedes phase 3, because a repaired +release meeting a plugin whose storefront presence nobody has written refuses to proceed — so shipping phase 3 first +would stop every release until someone did phase 1 under release pressure. Phase 3 precedes phase 4, so the correction +is durable rather than something the next release undoes. Phases 5 and 6 of the source are one change, which is this +document's phase 5. The work-items fix shares no actor, artifact, or failure mode with any of it, and sits at position 2 +because that is where the source put it and nothing argues for moving it. + +**Phases deliberately deferred:** None are added here. The source artifact already carries six deferrals under +[Deferred (YAGNI)](feature-specification.md#deferred-yagni), each with the trigger that would reopen it, and re-listing +them as phases would create a second place to maintain the same decisions. That section is canonical; this outline does +not restate it. + +**Where to look next:** The [Build Phase Index](#build-phase-index) lists every phase in order. Detailed write-ups +follow under [Build Phases](#build-phases). Decisions the team still owes itself are at [Open Questions](#open-questions) +— none of them blocks phase 1 from starting. + +--- + +## Build Phase Index {#build-phase-index} + +> The scan view. One row per phase, in build order. Each "Outcome" cell is one short sentence. Detailed write-ups follow +> under [Build Phases](#build-phases); use the link in the Phase column. + +| # | Phase | Kind | Outcome (one sentence) | +| --- | ------------------------------------------------------------------------ | ------------- | -------------------------------------------------------------------------------- | +| 1 | [Make the Linear plugin installable on channel two](#phase-1) | Feature slice | The documented install command for the Linear plugin succeeds instead of erroring. | +| 2 | [Stop the work-items publisher dropping work silently](#phase-2) | Feature slice | Every work item in a file is published, skipped-and-counted, or surfaced. | +| 3 | [Make a release bring every publishing target up to date](#phase-3) | Feature slice | A release repairs all four targets, reports what it changed, and refuses real gaps. | +| 4 | [Unfreeze channel two's version numbers](#phase-4) | Feature slice | Channel two publishes each plugin's real version, so updates are offered again. | +| 5 | [Delete the untrue dependencies and correct what described them](#phase-5) | Feature slice | Three plugins stop dragging in a plugin they never use, and the docs agree. | +| 6 | [Show the gap on the pull request that introduces it](#phase-6) | Polish | A contributor sees a missing target on their pull request, and it lands green. | + +> Numbers are assigned in build order and are stable for the life of this outline. Cite them as `Phase N` in tickets, +> comments, and follow-up reports. They do not match the source artifact's step numbers — each phase names its source +> step in its write-up. + +--- + +## Phase Kinds {#phase-kinds} + +Every phase is tagged with one of four kinds. The taxonomy is used in the Build Phase Index and on each phase entry's +`**Kind.**` line. + +- **Foundation** — A capability that does not deliver new user-facing features on its own, but is required for later + phases. Must still be demoable in its own right. +- **Feature slice** — A thin end-to-end strip of new behavior that a real user can experience. +- **Polish** — Branding, refinement, observability, or quality-of-life work that enriches a working core. +- **Deferred** — Listed for traceability; not built in the current plan. Slotted at the end of the index. + +--- + +## Build Phases {#build-phases} From 7b7f2a57e523770b214f5fcdb6b53bf46eebad3a Mon Sep 17 00:00:00 2001 From: River Lynn Bailey <river.bailey@testdouble.com> Date: Fri, 17 Jul 2026 07:07:29 -0600 Subject: [PATCH 07/22] docs(plans): add phase 1 to the build phase outline --- .../build-phase-outline.md | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/docs/plans/han-publishing-cleanup/build-phase-outline.md b/docs/plans/han-publishing-cleanup/build-phase-outline.md index 0b3fe1d7..6910e544 100644 --- a/docs/plans/han-publishing-cleanup/build-phase-outline.md +++ b/docs/plans/han-publishing-cleanup/build-phase-outline.md @@ -130,3 +130,59 @@ Every phase is tagged with one of four kinds. The taxonomy is used in the Build --- ## Build Phases {#build-phases} + +### Phase 1: Make the Linear plugin installable on channel two {#phase-1} + +**Kind.** Feature slice. + +**Builds on.** Nothing — this is the starting phase. + +**What we build.** The Linear plugin gets its entry in channel two's storefront listing and its own version record on +that channel, created at the version channel one already publishes for it, so it is correct the moment it exists. + +A person writes that record, and that is the point rather than an inconvenience. The record is not a version with some +description attached to it — it **is** the plugin's storefront presence: what it is called, how it is described, what +someone would ask it to do. None of that can be derived from anything the repository already holds. This is the same +boundary phase 3's release stops at, met here by the one actor who can cross it. + +**Why this is Phase 1.** Two reasons, and the second is the binding one. First, this is the only place where a person +following Han's own published instructions hits an error right now: channel two's setup instructions advertise the Linear +plugin, and it was never published there. Second, and this is easy to miss, phase 3 cannot safely land before it. Once a +release derives its plugins from the repository it sees the Linear plugin, finds no authored presence for it on channel +two, and refuses — so a repository with phase 3 and without phase 1 stops every release until someone writes that +presence by hand, which is this phase performed under release pressure instead of on purpose. + +**Outcome to demonstrate.** + +1. Run channel two's documented install command for the Linear plugin, as written in Han's own setup instructions, and + watch it error. This is today. +2. Ship the phase. +3. Run the same command again, unchanged. It succeeds and the plugin installs. +4. Look at what channel two now publishes for the plugin, and see the same version channel one publishes for it — not a + placeholder, and not a version behind. + +If channel two's client resolves from the latest release tag rather than the default branch, step 3 succeeds at the next +release rather than on merge; the demo is the same, the clock is different. See [OQ-1](#oq-1). + +**Source citations.** + +- [Step 1: Publish the Linear plugin to channel two](feature-specification.md#step-1-publish-the-linear-plugin-to-channel-two) — source position 1. +- [Channels and targets](feature-specification.md#channels-and-targets) — what a listing entry and a record each carry. +- [Outcome](feature-specification.md#outcome) — the install-succeeds promise this phase delivers first. + +**Connects to.** + +- Unblocks [Phase 3](#phase-3), which refuses to run against a plugin whose presence nobody has written. +- Feeds [Phase 4](#phase-4): the record this phase creates already agrees, so it is not one of the eight gaps phase 4 + closes. +- Creates one instance of the drift [Phase 4](#phase-4) repairs, if a release is cut before [Phase 3](#phase-3) lands. + Small, named, and healed by the same repair as the rest. + +**Preconditions to verify before starting.** + +- Confirm which revision channel two's client resolves from — the default branch or the latest release tag. It does not + change what this phase builds, only when it reaches anyone. See [OQ-1](#oq-1). +- Confirm nobody has written the Linear plugin's channel-two presence somewhere unpublished, so this phase is authoring + it rather than duplicating it. + +--- From f8957e28486df1960ec07d22cf80ab6df554e81f Mon Sep 17 00:00:00 2001 From: River Lynn Bailey <river.bailey@testdouble.com> Date: Fri, 17 Jul 2026 07:07:55 -0600 Subject: [PATCH 08/22] docs(plans): add phase 2 to the build phase outline --- .../build-phase-outline.md | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/docs/plans/han-publishing-cleanup/build-phase-outline.md b/docs/plans/han-publishing-cleanup/build-phase-outline.md index 6910e544..1f0086bb 100644 --- a/docs/plans/han-publishing-cleanup/build-phase-outline.md +++ b/docs/plans/han-publishing-cleanup/build-phase-outline.md @@ -186,3 +186,66 @@ release rather than on merge; the demo is the same, the clock is different. See it rather than duplicating it. --- + +### Phase 2: Stop the work-items publisher dropping work silently {#phase-2} + +**Kind.** Feature slice. + +**Builds on.** Nothing. This phase is independent of every other phase in this outline — it shares no actor, no +artifact, and no failure mode with the publishing work, and it is here because the source artifact retained it, not +because anything depends on it. + +**What we build.** Today, when the GitHub work-items publisher meets a work item already marked as published by a +different tracker, that item is neither published nor reported as skipped. It vanishes from the run with no error and no +count. After this phase, every work item in a file is accounted for in every run: published, skipped-and-counted, or +surfaced. + +Two properties make that promise real rather than circular: + +- **The whole file is examined before the first item is published.** A marking the publisher does not recognize, sitting + near the end of a file, cannot be preceded by items it already created. The run stops before creating anything at all. +- **Every heading the publisher cannot place is covered, not only the ones that look foreign.** The publisher cannot + tell "another tracker's marking in a shape I don't know" from "a hand-edited line with the wrong kind of dash" until it + has looked, and both need the same answer. The cheaper answer — publish what you understood, then complain — is the one + that creates tickets in a file that may already have been published somewhere else. + +The protection lives at every layer that inspects a heading, so a foreign marking reaches the stop rather than being +tidied away before it. The marking format itself does not change, and no migration is needed. + +**Why this is Phase 2.** Its position is free, and it is here because that is where the source artifact put it. It +blocks nothing and nothing blocks it, so moving it would trade traceability against the source for a tidier narrative +arc. What earns it an early slot rather than a late one is that it is a live defect losing people's work today, on the +same footing as phase 1. + +**Outcome to demonstrate.** + +1. Take a work-items file and publish it to one tracker, so its items are marked as published there. +2. Point the GitHub publisher at that same file. Today it reports some number published and some number skipped, and the + marked items are in neither count — they are simply gone, with no error. +3. Ship the phase. +4. Point the GitHub publisher at the same file again. It stops before creating anything, names the specific work items + whose markings it does not recognize and what they appear to be marked by, and creates nothing. +5. Add the two numbers it reports on an ordinary run to the number it surfaces, and get the number of work items in the + file. Nothing is unaccounted for. + +**Source citations.** + +- [Step 2: Close the GitHub publisher's silent hole](feature-specification.md#step-2-close-the-github-publishers-silent-hole) — source position 2. +- [Edge cases and failure modes](feature-specification.md#edge-cases-and-failure-modes) — the work-items rows. +- [User interactions](feature-specification.md#user-interactions) — what a format error names. +- [Out of scope](feature-specification.md#out-of-scope) — marking-namespacing is a separate specification, not this + phase. + +**Connects to.** + +- Nothing. This is the outline's one genuinely independent phase, and saying so is more useful than manufacturing a + connection. + +**Preconditions to verify before starting.** + +- Confirm the three work-items publishers all read and mark the same file in the way the source artifact assumes, since + this phase changes only how one of them responds to markings it does not recognize. +- Decide nothing about telling one tracker's markings from another's — that trap is real, is named in the source + artifact, and is deliberately not this phase's. + +--- From c86f4545d8723695e1426e72339a1376aaa7044f Mon Sep 17 00:00:00 2001 From: River Lynn Bailey <river.bailey@testdouble.com> Date: Fri, 17 Jul 2026 07:08:31 -0600 Subject: [PATCH 09/22] docs(plans): add phase 3 to the build phase outline --- .../build-phase-outline.md | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/docs/plans/han-publishing-cleanup/build-phase-outline.md b/docs/plans/han-publishing-cleanup/build-phase-outline.md index 1f0086bb..c22de451 100644 --- a/docs/plans/han-publishing-cleanup/build-phase-outline.md +++ b/docs/plans/han-publishing-cleanup/build-phase-outline.md @@ -249,3 +249,105 @@ same footing as phase 1. artifact, and is deliberately not this phase's. --- + +### Phase 3: Make a release bring every publishing target up to date {#phase-3} + +**Kind.** Feature slice. + +**Builds on.** [Phase 1](#phase-1). A release repaired by this phase refuses to run against a plugin whose channel-two +presence nobody has written, and that is the Linear plugin's shape until phase 1 lands. + +**What we build.** A release today updates two of the four targets, and takes its list of plugins from one of the targets +it also writes. It therefore cannot see that the other two have fallen behind, which is exactly why the drift went +unnoticed across eleven releases. After this phase a release derives the set of plugins from what is really in the +repository, and brings all four targets up to date. + +Bringing a target up to date has two halves, and today's release has neither: + +- **It corrects a version that has fallen behind**, including for a plugin it did not bump. Today a release writes a + version only for the plugins it bumps, which is why a record that drifted stays drifted no matter how many releases + run. +- **It creates a channel-two listing entry that does not exist yet.** Today's release can only write a version onto an + entry already present, so a plugin absent from a target stays absent forever. + +**The release creates what it can derive and stops at what must be authored,** and that line falls in exactly one place. +A channel-two listing entry says a plugin exists, where it lives, and the terms on which it installs — all derivable +from the plugin itself, none of it written prose. A per-plugin record on either channel is a different animal: it **is** +the plugin's storefront presence, so a release that created one would be inventing the prose rather than deriving a +version. This is the boundary phase 1 describes from the other side. + +**A release refuses to proceed when it meets a gap it cannot close:** a plugin with no record on a channel it belongs +in; a listing naming a plugin that is not in the repository; a record or listing it cannot read; a version value it +cannot make sense of; a plugin whose publishing version it cannot determine. Every one of them is a person's decision +wearing a release's clothes. + +Three more properties come with the phase: + +- **The gate runs on the state being released** — after all four targets are brought up to date, and before anything is + committed, tagged, pushed, or published. When it stops, it names every gap it found rather than the first. +- **Everything the release wrote travels into the commit it tags**, so the state the gate passed is the state that + ships. +- **A release reports what it created and what it changed** — which plugin, which targets, at what version — alongside + the version plan it already reports. The whole defect being repaired is that something quietly stopped happening to + what Han publishes; a repair that quietly starts happening is the same shape wearing better clothes. + +The release holds no copy of the rule. It runs the check and reports what the check says. One exception is permanent and +named: the bundle cannot be published to channel two, so on that channel the rule does not flag its absence, does not +ask it to agree with a record it does not have, and **does not create one for it** — it is the one plugin a helpful +release would publish to a channel that cannot install it. That exception stops at channel two; the bundle's two +channel-one records are held to agreement like any other plugin's. This phase also corrects the documents describing the +release procedure, because this phase is what makes them wrong. + +**Why this is Phase 3.** It is the hinge, and it lands here rather than earlier or later for two separate reasons. It +cannot come before phase 1, because a repaired release meeting the Linear plugin's unwritten presence refuses rather +than repairs, which stops every release until a person writes it. It must come before phase 4, because the repair is +what makes the correction durable — a version fixed by hand before the release is repaired is a version the next release +re-freezes. Everything downstream either depends on it or is made honest by it. + +**Outcome to demonstrate.** + +1. Point at a plugin whose channel-two version has fallen behind, and note the number channel two currently publishes for + it. +2. Delete that plugin's channel-two listing entry, so the repository is in the exact shape a contributor leaves behind + when they add a plugin and forget a target. +3. Cut a release. Watch it read the plugin list from the repository rather than from a listing, name its version plan, + and ask for confirmation. +4. Watch it report what it changed and what it created: the stale version corrected, the missing listing entry recreated + at the version being published, both named explicitly rather than happening quietly. +5. Look at all four targets and see them agree. Look at the commit the release tagged and see all four inside it — the + state the gate passed is the state that shipped. +6. Now delete a plugin's channel-two record instead, and cut a release again. It stops before committing anything, names + that gap and every other gap it found in the same run, and publishes nothing. Nothing is tagged and nothing is pushed. +7. Add a fake entry to a listing for a plugin that does not exist in the repository, and cut a release. It stops for that + too, rather than deciding on its own whether the plugin or the entry is the mistake. + +**Source citations.** + +- [Step 3: Teach the release process about every target](feature-specification.md#step-3-teach-the-release-process-about-every-target) — source position 6, moved ahead of the version correction. +- [Coordinations](feature-specification.md#coordinations) — the release and the repository, the release and both + storefronts, the release and the commit it tags, the check and the release. +- [Alternate flows and states](feature-specification.md#alternate-flows-and-states) — what a release does when it meets + each gap, and what recovery from a stop costs. +- [Edge cases and failure modes](feature-specification.md#edge-cases-and-failure-modes) — the bundle rows, the unreadable + listing and record rows, the half-finished removal row. + +**Connects to.** + +- Depends on [Phase 1](#phase-1) having written the Linear plugin's presence. +- Makes [Phase 4](#phase-4)'s hand correction durable rather than something the next release undoes. +- Corrects the contributor guide that makes [Phase 6](#phase-6)'s signal fair — a contributor cannot be told they missed + a target the guide never named. +- Puts the rule into service four phases before [Phase 6](#phase-6) makes it visible on a pull request, because the + release runs it from here onward. + +**Preconditions to verify before starting.** + +- Confirm [Phase 1](#phase-1) has landed on the branch this work builds from. Without it, the first release after this + phase stops rather than repairs. +- Confirm what a release should do when the operator's approved version plan and a target's repair disagree, since the + plan is confirmed before the targets are written and the gate cannot run until after. +- Decide who owns re-running a release after a gate stop, given that recovery costs a separate commit: the release's own + local work is discarded first, then the gap is corrected and committed on its own, then the release is planned from + scratch. + +--- From 3fbb110e78f61b45be7e1e6c729f4882386285d0 Mon Sep 17 00:00:00 2001 From: River Lynn Bailey <river.bailey@testdouble.com> Date: Fri, 17 Jul 2026 07:08:56 -0600 Subject: [PATCH 10/22] docs(plans): add phase 4 to the build phase outline --- .../build-phase-outline.md | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/docs/plans/han-publishing-cleanup/build-phase-outline.md b/docs/plans/han-publishing-cleanup/build-phase-outline.md index c22de451..d6b8e28d 100644 --- a/docs/plans/han-publishing-cleanup/build-phase-outline.md +++ b/docs/plans/han-publishing-cleanup/build-phase-outline.md @@ -351,3 +351,59 @@ re-freezes. Everything downstream either depends on it or is made honest by it. scratch. --- + +### Phase 4: Unfreeze channel two's version numbers {#phase-4} + +**Kind.** Feature slice. + +**Builds on.** [Phase 3](#phase-3), which is what makes this correction durable rather than something the next release +re-freezes. Also [Phase 1](#phase-1), whose record arrives already agreeing and so is not one of the gaps this phase +closes. + +**What we build.** Channel two's published version numbers have not moved since the day they were created. This phase +corrects each of them by hand, so every plugin's channel-two version matches what channel one publishes for that same +plugin. Eight plugins carry a version that has fallen behind, and eight is the whole set. + +**Why this is Phase 4.** It comes after the repair rather than before it, and the reason is durability: had it come +first, any release cut before phase 3 would have undone it. It is worth doing by hand at all even though phase 3 makes a +release do it, and the honest reason is not the obvious one. The obvious reason — the numbers are right on merge rather +than whenever someone next cuts a release — is contingent on an unknown. If channel two's client resolves from the +release tag, the only revision anyone resolves is one a release produced, and a release corrects these numbers itself; the +user-facing benefit is then not smaller but nothing, and what survives is that the repository stops publishing numbers it +knows are wrong ([OQ-1](#oq-1)). The reason that does not depend on that unknown is that **this phase is what makes phase +6's check green on the day it arrives.** Without it, and absent a release cut in between, the check lands red against +eight disagreements, and a signal that is red from birth is one people learn to scroll past. + +**Outcome to demonstrate.** + +1. Line up what channel one publishes for each plugin against what channel two publishes for the same plugin. Eight + pairs disagree, and channel two's side of each has not moved since the day it was written. +2. Ship the phase. +3. Line them up again. Every pair agrees. +4. Take a client that has Han installed from channel two and check for updates. An update is offered, which is what this + whole channel has stopped doing — confirming [OQ-2](#oq-2) along the way, since this is the demo that answers it. +5. Cut a release. Watch the numbers stay corrected rather than re-freeze, which is what phase 3 bought. + +Step 4 rests on an unverified assumption about how channel two decides an update exists. If it turns out to be wrong, +steps 1 through 3 and 5 still hold and the update claim comes out. See [OQ-2](#oq-2). + +**Source citations.** + +- [Step 4: Correct the frozen version numbers](feature-specification.md#step-4-correct-the-frozen-version-numbers) — source position 3, moved after the release repair. +- [Outcome](feature-specification.md#outcome) — the update-availability promise, and its hedge. +- [Open items](feature-specification.md#open-items) — items 1 and 2, both of which shape how this phase's benefit is + worded. + +**Connects to.** + +- Depends on [Phase 3](#phase-3) for durability, and on [Phase 1](#phase-1) for the ninth pair not existing. +- Makes [Phase 6](#phase-6) green on arrival, which is the reason that does not depend on any unknown. + +**Preconditions to verify before starting.** + +- Confirm whether channel two decides update availability from the published version number. The correction is worth + making either way, but the user-facing claim depends on it. See [OQ-2](#oq-2). +- Confirm no release was cut between [Phase 1](#phase-1) and [Phase 3](#phase-3). If one was, the Linear plugin's + channel-one version moved while its new record stayed put, and this phase closes nine gaps rather than eight. + +--- From af8750bf07e020836bf3d14b0fda41d3df16e648 Mon Sep 17 00:00:00 2001 From: River Lynn Bailey <river.bailey@testdouble.com> Date: Fri, 17 Jul 2026 07:09:29 -0600 Subject: [PATCH 11/22] docs(plans): add phase 5 to the build phase outline --- .../build-phase-outline.md | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/docs/plans/han-publishing-cleanup/build-phase-outline.md b/docs/plans/han-publishing-cleanup/build-phase-outline.md index d6b8e28d..ced49919 100644 --- a/docs/plans/han-publishing-cleanup/build-phase-outline.md +++ b/docs/plans/han-publishing-cleanup/build-phase-outline.md @@ -407,3 +407,87 @@ steps 1 through 3 and 5 still hold and the update claim comes out. See [OQ-2](#o channel-one version moved while its new record stayed put, and this phase closes nine gaps rather than eight. --- + +### Phase 5: Delete the untrue dependencies and correct what described them {#phase-5} + +**Kind.** Feature slice. + +**Builds on.** Nothing in this outline. Its position among the other phases is free; what is not free is that its two +halves ship as one change. + +**What we build.** Three plugins declare that they need the core plugin and never touch it. The reporting plugin's use +moved elsewhere and the declaration was left behind. The feedback plugin and the Linear plugin are not permitted to +invoke other plugins at all, so their declarations cannot be true — neither is granted the means to call one. This phase +deletes all three, and in the same change corrects every document that described them. + +After it, the reporting plugin declares only what it uses, the feedback and Linear plugins declare nothing, installing +any of them no longer drags in a large plugin the installer will never use, and every remaining declaration in the suite +is one the declaring plugin actually uses. + +**The two halves are one change, and that is the whole reason this is one phase.** A merged state where the declarations +are gone and the documents still narrate them is precisely the state the document correction exists to prevent. Nothing +in the repository enforces that they land together, so it is a commitment about how the work ships rather than a property +of the work. + +The documents in scope are decided by a rule, not a list: **a document is in scope when this work falsifies it.** Not +"mentions a plugin", not "narrates topology", not "sits nearby". That reaches the contributor guide, the orientation +document new readers are told to read first, the canonical long-form docs for the affected plugins, the plugin-selection +guide, the agent-facing project map, and the tutorial that teaches plugin dependencies. Two refinements matter: + +- **A document making a universal claim about the dependency graph states the rule and points at the manifests as the + record.** That remedy stops at universal claims, because no rule generates this suite's graph — it is irregular on + purpose. A document whose job is to orient a reader legitimately enumerates it, so those documents keep the + enumeration, drop any hardcoded count, and name the manifests as the record. +- **An already-false statement inside a passage this work rewrites is corrected; one elsewhere is not.** A passage is + the paragraph. That boundary is what keeps the rule from collapsing into either uselessness or an open-ended audit. + +The tutorial that teaches dependencies by walking through the deleted edges has its worked example repointed at edges +that remain real. The replacement must be an edge this work leaves standing **and** one that is actually true — the +plugin the rewrite originally planned to point at turned out to carry the third untrue declaration, so repointing there +would have taught the lesson using a counter-example to itself. + +**Why this is Phase 5.** Its position is free and it lands here because nothing argues for it landing earlier. It does +not block the publishing repair, and the publishing repair does not block it. What matters is that it precedes +[Phase 6](#phase-6) only incidentally — the check does not inspect dependency declarations or documents at all, so this +phase is not what makes the check green. One dependency of a different kind is real: the survey behind the document half +originally ran against a two-plugin premise and must be re-run against the third declaration, which is the same rule +applied to a corrected set rather than a change to the rule. + +**Outcome to demonstrate.** + +1. Install the reporting plugin on its own, and watch the core plugin arrive with it, unasked for and unused. +2. Ship the phase. +3. Install the reporting plugin again. The core plugin does not come with it, and the reporting plugin still works. +4. Do the same with the feedback plugin and the Linear plugin. Neither drags anything in, and both still work. +5. Open the contributor guide, the orientation document, the plugin-selection guide, and the agent-facing project map, + and find no description of a dependency the suite does not have — and no hardcoded count where an enumeration used to + claim one. +6. Read the dependency tutorial end to end. Its worked example walks an edge that still exists and is actually true, and + it no longer promises to print real version numbers. + +**Source citations.** + +- [Step 5: Delete the three untrue dependency declarations](feature-specification.md#step-5-delete-the-three-untrue-dependency-declarations) — source position 4. +- [Step 6: Correct every document the declaration deletion falsifies](feature-specification.md#step-6-correct-every-document-the-declaration-deletion-falsifies) — source position 5, shipped with step 5. +- [Coordinations](feature-specification.md#coordinations) — step 5 and step 6 as a single change. +- [Deferred (YAGNI)](feature-specification.md#deferred-yagni) — a standing check for decorative dependencies is + deliberately not built here. + +**Connects to.** + +- Independent of [Phase 3](#phase-3) and [Phase 4](#phase-4): a release repairs targets and does not inspect + declarations. +- Not a precondition for [Phase 6](#phase-6). The check does not look at dependency declarations, so this phase neither + reddens nor greens it. +- Documents the release procedure falsifies are [Phase 3](#phase-3)'s, not this phase's. Each step owns the documents it + breaks. + +**Preconditions to verify before starting.** + +- Re-run the document survey against all three declarations rather than the two the original survey assumed. The rule + already reaches the extra locations; the list from the earlier pass does not. +- Confirm the replacement edge for the tutorial's worked example is both surviving and true, since the obvious candidate + was one of the three being deleted. +- Agree that the two halves ship in a single change, because nothing in the repository enforces it. + +--- From f2edeff04bdcf97fe26bea6ae8587ebdf2ff219e Mon Sep 17 00:00:00 2001 From: River Lynn Bailey <river.bailey@testdouble.com> Date: Fri, 17 Jul 2026 07:10:14 -0600 Subject: [PATCH 12/22] docs(plans): add phase 6 and open questions to the build phase outline --- .../build-phase-outline.md | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/docs/plans/han-publishing-cleanup/build-phase-outline.md b/docs/plans/han-publishing-cleanup/build-phase-outline.md index ced49919..55431ca7 100644 --- a/docs/plans/han-publishing-cleanup/build-phase-outline.md +++ b/docs/plans/han-publishing-cleanup/build-phase-outline.md @@ -491,3 +491,159 @@ applied to a corrected set rather than a change to the rule. - Agree that the two halves ship in a single change, because nothing in the repository enforces it. --- + +### Phase 6: Show the gap on the pull request that introduces it {#phase-6} + +**Kind.** Polish. + +**Builds on.** [Phase 3](#phase-3), which is where the rule actually comes into existence, and [Phase 4](#phase-4), +which is what makes this phase land green rather than red against eight disagreements. Phases 1 through 5 have all +landed by the time this one does. + +**What we build.** The rule becomes visible on a pull request. It verifies that every plugin in the repository appears in +every target it belongs in, and that a plugin's version records agree. It runs on every pull request, and additionally on +the machines of contributors who installed the optional local hooks. + +The rule itself is not new here. It has existed since [Phase 3](#phase-3), because the release runs it, and it has been +refusing releases since then over the gaps a release cannot close. What this phase adds is putting the same answer in +front of a contributor before a maintainer meets it at release time. + +**The two surfaces ask one question and get different answers, and that is the point rather than an inconsistency.** On a +pull request the rule reports what is wrong **now**, including drift a release would have repaired on its own. On a +release it reports what is wrong **after** the release has repaired everything it can, so what is left is only what a +person must decide. The contributor sees the gap; the maintainer sees the residue. + +**What this phase does not do is make the rule blocking.** A pull-request check prevents a merge only where the hosting +platform is configured to require it, and this repository has no such configuration. A red check here is a signal a +person can merge past. The surface that actually refuses is the release. That is still worth having on its own terms: it +moves the discovery of a gap from release day to the pull request that introduced it, which is where it is cheapest to +fix and where the person who caused it is still holding the context. Making it genuinely blocking is a repository-settings +change no phase here owns ([OQ-3](#oq-3)). + +There is no disable switch, deliberately. The check lands in one change, so reverting that change is the escape hatch. + +**Why this is Phase 6.** This is the one hard ordering constraint the source plan argued for, and the reason survives +restating. Landing the check before the gaps are fixed produces a signal that is red from the day it arrives, and a +signal that is permanently red is one people learn to scroll past. Because phases 1 through 5 have landed, it is green on +arrival. It does not stay green by construction — it goes red on the pull request that introduces a gap, a person may +merge past it, and the branch then carries a red check until the next release repairs the gap. That is the same failure +mode arriving where it actually lives. + +**Outcome to demonstrate.** + +1. Ship the phase and open any ordinary pull request. The check runs and it is green. This is the day-one state, and it + is green because phases 1 through 5 landed first. +2. Open a pull request that adds a new plugin directory with a version record on channel one and nothing else — the exact + thing a contributor does when they add a plugin and forget the rest. +3. Watch the check fail and name which targets the plugin is missing from. Not the first one: all of them. +4. Watch a person merge it anyway, because nothing here blocks a merge, and see the branch carry a red check. +5. Cut a release from that branch. It stops, because the new plugin has no written presence on channel two — so merging + past the signal costs a stopped release rather than merely a late fix. +6. Write the plugin's presence, and cut the release again. It proceeds, creates the listing entry itself, and reports + that it did. + +**Source citations.** + +- [Step 7: Turn on the automated check](feature-specification.md#step-7-turn-on-the-automated-check) — source position 7. +- [Alternate flows and states](feature-specification.md#alternate-flows-and-states) — what a new plugin merged past the + check costs. +- [Coordinations](feature-specification.md#coordinations) — the check and the pull-request pipeline; the check and the + release process. +- [Open items](feature-specification.md#open-items) — item 3, the unmade decision about making the check required. + +**Connects to.** + +- Depends on [Phase 3](#phase-3) for the rule, and on [Phase 4](#phase-4) for landing green. +- Depends on [Phase 3](#phase-3)'s contributor-guide correction for the signal to be fair: a contributor cannot fairly + be told they missed a target the guide never named. +- Independent of [Phase 5](#phase-5), which the check does not inspect. + +**Preconditions to verify before starting.** + +- Confirm phases 1 through 5 have landed, or that a release was cut after phase 3. Otherwise the check lands red and the + reason for sequencing it last is thrown away. +- Decide whether to make the check required, which is a repository-settings change no phase owns. It does not block this + phase. See [OQ-3](#oq-3). + +--- + +## Open Questions {#open-questions} + +> Decisions or verifications the team must resolve, ordered by the lowest-numbered phase they shape. None of them blocks +> phase 1 from starting. Cite them as `OQ-N` in follow-up. +> +> All four come from the source artifact's [Open items](feature-specification.md#open-items) and are restated here with +> the phases they touch. The source artifact remains canonical for their full history. + +### OQ-1. Which revision does each channel's client resolve from — the default branch, or the latest release tag? {#oq-1} + +**Blocks phase(s).** None. Shapes [Phase 1](#phase-1) and [Phase 4](#phase-4). + +This is not visible from inside the repository. If clients resolve from the release tag, then phases 1 and 4 reach users +at the next release rather than on merge. Both phases are worded to hold either way, so this changes when the fix arrives +rather than whether it works. It also weakens phase 1's rationale for going first without changing the answer, since +phase 1 is a binding constraint before phase 3 regardless. + +- **Option A — Verify before quoting either phase's timing to anyone.** Costs one installed client and one release. Both + phases proceed either way; only the claims about when users see the fix depend on the answer. +- **Option B — Leave it unverified and keep both phases hedged.** Free, and the current wording already survives both + answers. The cost is that nobody can honestly say when a fix reaches a user. +- **Recommendation: Option A, but not as a gate.** Start phase 1 now. The verification costs a release you are going to + cut anyway, and phase 1's ordering does not depend on the answer. What depends on it is the sentence you put in front + of someone, so answer it before you make the promise, not before you start the work. + +### OQ-2. Does channel two decide update availability from the published version number? {#oq-2} + +**Blocks phase(s).** None. Shapes [Phase 4](#phase-4) and the Outcome's update claim. + +The source artifact's promise that channel-two users are offered updates again rests entirely on this. It is unconfirmed. +This is the one open question with a named cost and a named consequence: verifying costs one installed client and one +release, and if it is wrong then phase 4 is still worth doing but its user-facing claim comes out. + +- **Option A — Verify during phase 4's own demo.** Step 4 of that phase's demo is exactly this test. It costs nothing + extra, and it answers the question at the moment the answer becomes useful. +- **Option B — Verify before starting phase 4.** Costs a release ahead of the work. Buys the ability to describe the + benefit correctly before committing to the work, which matters only if the benefit is what justifies the work. +- **Recommendation: Option A.** Phase 4 has a reason that does not depend on this answer at all — it is what makes + phase 6's check green on arrival. So the work is justified either way, and the verification belongs in the demo rather + than ahead of it. Owner: the maintainer, before the update claim is quoted to anyone. + +### OQ-3. Should the pull-request check be made required? {#oq-3} + +**Blocks phase(s).** None. Shapes what [Phase 6](#phase-6) is worth. + +This is a decision, not an unknown. The fact is settled: the default branch is unprotected, no rules apply to it, and the +sole ruleset is disabled and contains no required-check rule, so enabling it as written would not help. No phase in this +outline owns the repository-settings change. + +- **Option A — Leave the check advisory.** Phase 6 still moves discovery of a gap to the pull request that introduced it, + and the release still refuses what matters. The cost is that a contributor can merge past a signal, and for a brand-new + plugin that stops the next release rather than merely delaying a fix. +- **Option B — Require the check alone.** Makes the pull-request surface actually block. Worth knowing: the existing + disabled ruleset also demands an approving review, which on a solo-maintained repository would block the maintainer + from merging their own work, and is the likeliest reason it is switched off. Requiring the check alone is the smaller + move. +- **Recommendation: Option B, after phase 6 has been green for a while.** The advisory version is tolerable, but its + tolerability rests on the release refusing to publish something nobody wrote — a protection rather than a repair. Once + the check has run green across a few real pull requests and produced no false positives, requiring it alone costs + nothing and closes the gap. Enabling the ruleset as it currently stands is the move to avoid. Owner: the maintainer. + +### Carry-over notes + +### OQ-4. What happens when plugins of different vintages are installed against each other? {#oq-4} + +**Blocks phase(s).** None — carry-over note. + +Plugins are installed and updated one at a time, so someone can run a months-old coding plugin against a core plugin +updated today, and nothing would notice. An earlier argument that this cannot happen — everything ships from a single +snapshot — holds only for a fresh install of everything at once, which is not how the suite is used over time. + +The right fix is not obvious, and this is flagged as a decision the team still owes itself rather than work this outline +schedules. It is kept here rather than dropped because it has nowhere else to live: no phase depends on it and no +follow-up specification carries it. An open question with no home stays with the document that found it. + +--- + +_End of outline. If you need to cite a specific phase elsewhere, use its `Phase N` number — those numbers are stable for +the life of this document, and they do not match the source artifact's step numbers. If you need to cite a specific open +question, use its `OQ-N` ID._ From 8f230d7d913c3e2b5b269ae07274fb6dfd8c60ba Mon Sep 17 00:00:00 2001 From: River Lynn Bailey <river.bailey@testdouble.com> Date: Fri, 17 Jul 2026 07:21:56 -0600 Subject: [PATCH 13/22] docs(plans): apply information-architect findings to the build phase outline - Add Builds on and Source step columns to the index; name the one real ID collision (Phase 6 vs Step 6) instead of the wrong one - Renumber open questions to match the spec's Open items 1-4; promote a decision buried in phase 3's preconditions to OQ-5 - Restore the spec's does-not-partition-evenly clause to the vocabulary, disambiguate publishing a plugin from publishing a work item, drop the hardcoded term count - Revert marking -> annotation to keep phase 2 traceable to its source - Generalize the platform vocabulary in OQ-3 - Reconcile the phase-kinds taxonomy with the two kinds actually used - Cut phase 1's unevidenced precondition; add deferral scent to the index --- .../build-phase-outline.md | 226 +++++++++++------- 1 file changed, 135 insertions(+), 91 deletions(-) diff --git a/docs/plans/han-publishing-cleanup/build-phase-outline.md b/docs/plans/han-publishing-cleanup/build-phase-outline.md index 55431ca7..76833e1b 100644 --- a/docs/plans/han-publishing-cleanup/build-phase-outline.md +++ b/docs/plans/han-publishing-cleanup/build-phase-outline.md @@ -10,9 +10,7 @@ generated_by: "han-planning:plan-a-phased-build" This document describes the order in which the Han publishing cleanup will be built. The work is broken into a sequence of **phases**, where each phase is a thin end-to-end deliverable that can be demonstrated to a real person, and each -phase builds on the one before it. The cleanup repairs how Han is published to the two places it ships to, so that -everything advertised as installable actually installs, everyone who installed it is offered updates again, and no -future release can quietly stop either being true. +phase builds on the one before it. This document is the companion to [feature-specification.md](feature-specification.md). The source artifact describes _what is broken today, what each of its seven steps changes, and why they are ordered the way they are_. This document @@ -33,12 +31,17 @@ phase_. Every phase below cites the source-artifact sections it covers, so anyon - [Phase 5: Delete the untrue dependencies and correct what described them](#phase-5) - [Phase 6: Show the gap on the pull request that introduces it](#phase-6) - [Open Questions](#open-questions) + - [OQ-1: Does channel two decide update availability from the published version number?](#oq-1) + - [OQ-2: Which revision does each channel's client resolve from?](#oq-2) + - [OQ-3: Should the pull-request check be made required?](#oq-3) + - [OQ-4 (carry-over): What happens when plugins of different vintages are installed against each other?](#oq-4) + - [OQ-5: What should a release do when the approved version plan and a target's repair disagree?](#oq-5) --- ## Vocabulary {#vocabulary} -The source artifact stays deliberately neutral about which storefront is which, and this document inherits that. Four +The source artifact stays deliberately neutral about which storefront is which, and this document inherits that. These words carry the whole outline. - **Channel one** and **channel two** — the two places Han is published. Channel one is healthy; nothing here changes @@ -47,8 +50,12 @@ words carry the whole outline. - **A version record** — a place that states a plugin's published version. There are three: each channel carries one per plugin, and channel one's listing also carries a version per plugin. - **A target** — any of the four places a release must keep current: the two storefront listings and the two per-plugin - records. + records. Channel one's listing is both a listing and a version record, which is why the four targets do not partition + evenly into "two and two", and why there are three version records rather than two. - **The bundle** — the meta-plugin that installs the suite in one command. It exists on channel one only. +- **Publishing a plugin, and publishing a work item, are unrelated.** A plugin is published to a **channel**, which is + what phases 1, 3, 4, and 6 are about. A work item is published to a **tracker**, which is what [Phase 2](#phase-2) is + about and nothing else here is. The word does double duty; the two senses share nothing. The full grounding, including what each target carries besides a version, is in the source artifact's [Channels and targets](feature-specification.md#channels-and-targets). @@ -101,31 +108,41 @@ follow under [Build Phases](#build-phases). Decisions the team still owes itself > The scan view. One row per phase, in build order. Each "Outcome" cell is one short sentence. Detailed write-ups follow > under [Build Phases](#build-phases); use the link in the Phase column. -| # | Phase | Kind | Outcome (one sentence) | -| --- | ------------------------------------------------------------------------ | ------------- | -------------------------------------------------------------------------------- | -| 1 | [Make the Linear plugin installable on channel two](#phase-1) | Feature slice | The documented install command for the Linear plugin succeeds instead of erroring. | -| 2 | [Stop the work-items publisher dropping work silently](#phase-2) | Feature slice | Every work item in a file is published, skipped-and-counted, or surfaced. | -| 3 | [Make a release bring every publishing target up to date](#phase-3) | Feature slice | A release repairs all four targets, reports what it changed, and refuses real gaps. | -| 4 | [Unfreeze channel two's version numbers](#phase-4) | Feature slice | Channel two publishes each plugin's real version, so updates are offered again. | -| 5 | [Delete the untrue dependencies and correct what described them](#phase-5) | Feature slice | Three plugins stop dragging in a plugin they never use, and the docs agree. | -| 6 | [Show the gap on the pull request that introduces it](#phase-6) | Polish | A contributor sees a missing target on their pull request, and it lands green. | - -> Numbers are assigned in build order and are stable for the life of this outline. Cite them as `Phase N` in tickets, -> comments, and follow-up reports. They do not match the source artifact's step numbers — each phase names its source -> step in its write-up. +| # | Phase | Kind | Builds on | Source step | Outcome (one sentence) | +| --- | ------------------------------------------------------------------------- | ------------- | ----------- | ----------- | ---------------------------------------------------------------------------------- | +| 1 | [Make the Linear plugin installable on channel two](#phase-1) | Feature slice | — | Step 1 | The documented install command for the Linear plugin succeeds instead of erroring. | +| 2 | [Stop the work-items publisher dropping work silently](#phase-2) | Feature slice | — | Step 2 | Every work item in a file is published, skipped-and-counted, or surfaced. | +| 3 | [Make a release bring every publishing target up to date](#phase-3) | Feature slice | Phase 1 | Step 3 | A release repairs all four targets, reports what it changed, and refuses real gaps. | +| 4 | [Unfreeze channel two's version numbers](#phase-4) | Feature slice | Phases 1, 3 | Step 4 | Channel two publishes each plugin's real version, so updates are offered again. | +| 5 | [Delete the untrue dependencies and correct what described them](#phase-5) | Feature slice | — | Steps 5+6 | Three plugins stop dragging in a plugin they never use, and the docs agree. | +| 6 | [Show the gap on the pull request that introduces it](#phase-6) | Polish | Phases 3, 4 | Step 7 | A contributor sees a missing target on their pull request, and it lands green. | + +> Phase numbers are assigned in build order and are stable for the life of this outline. Cite them as `Phase N` in +> tickets, comments, and follow-up reports. +> +> **Phase numbers and the source artifact's step numbers agree up to 4 and then diverge**, because this outline merges +> the source's steps 5 and 6 into one phase. So "6" means the check here and the document correction there — the one +> collision worth knowing about, and the reason the Source step column exists. Say "Phase 6" or "Step 6", never a bare +> "6". A third numbering, the source's own "source position", records where each step sat in the original cleanup plan +> before it was resequenced; it appears only in the source artifact and nothing here depends on it. +> +> Six items were considered and deliberately deferred; the source artifact's +> [Deferred (YAGNI)](feature-specification.md#deferred-yagni) is canonical for all six and this outline does not restate +> them. --- ## Phase Kinds {#phase-kinds} -Every phase is tagged with one of four kinds. The taxonomy is used in the Build Phase Index and on each phase entry's -`**Kind.**` line. +The standard taxonomy is four kinds. **This outline uses two of them**, and the other two classify nothing here — there +is no foundation phase, because every phase turned out to be demoable end to end on its own, and there are no deferred +phases, because the source artifact already carries every deferral. -- **Foundation** — A capability that does not deliver new user-facing features on its own, but is required for later - phases. Must still be demoable in its own right. -- **Feature slice** — A thin end-to-end strip of new behavior that a real user can experience. -- **Polish** — Branding, refinement, observability, or quality-of-life work that enriches a working core. -- **Deferred** — Listed for traceability; not built in the current plan. Slotted at the end of the index. +- **Feature slice** — A thin end-to-end strip of new behavior that a real user can experience. Five of the six phases. +- **Polish** — Refinement, observability, or quality-of-life work that enriches a working core. [Phase 6](#phase-6) + only. +- **Foundation** and **Deferred** — the two unused kinds, named here only so a reader who knows the taxonomy is not left + hunting for rows that do not exist. --- @@ -162,11 +179,11 @@ presence by hand, which is this phase performed under release pressure instead o placeholder, and not a version behind. If channel two's client resolves from the latest release tag rather than the default branch, step 3 succeeds at the next -release rather than on merge; the demo is the same, the clock is different. See [OQ-1](#oq-1). +release rather than on merge; the demo is the same, the clock is different. See [OQ-2](#oq-2). **Source citations.** -- [Step 1: Publish the Linear plugin to channel two](feature-specification.md#step-1-publish-the-linear-plugin-to-channel-two) — source position 1. +- [Step 1: Publish the Linear plugin to channel two](feature-specification.md#step-1-publish-the-linear-plugin-to-channel-two). - [Channels and targets](feature-specification.md#channels-and-targets) — what a listing entry and a record each carry. - [Outcome](feature-specification.md#outcome) — the install-succeeds promise this phase delivers first. @@ -181,9 +198,7 @@ release rather than on merge; the demo is the same, the clock is different. See **Preconditions to verify before starting.** - Confirm which revision channel two's client resolves from — the default branch or the latest release tag. It does not - change what this phase builds, only when it reaches anyone. See [OQ-1](#oq-1). -- Confirm nobody has written the Linear plugin's channel-two presence somewhere unpublished, so this phase is authoring - it rather than duplicating it. + change what this phase builds, only when it reaches anyone. See [OQ-2](#oq-2). --- @@ -195,22 +210,22 @@ release rather than on merge; the demo is the same, the clock is different. See artifact, and no failure mode with the publishing work, and it is here because the source artifact retained it, not because anything depends on it. -**What we build.** Today, when the GitHub work-items publisher meets a work item already marked as published by a +**What we build.** Today, when the GitHub work-items publisher meets a work item already annotated as published by a different tracker, that item is neither published nor reported as skipped. It vanishes from the run with no error and no count. After this phase, every work item in a file is accounted for in every run: published, skipped-and-counted, or surfaced. Two properties make that promise real rather than circular: -- **The whole file is examined before the first item is published.** A marking the publisher does not recognize, sitting - near the end of a file, cannot be preceded by items it already created. The run stops before creating anything at all. +- **The whole file is examined before the first item is published.** An annotation the publisher does not recognize, + sitting near the end of a file, cannot be preceded by items it already created. The run stops before creating anything at all. - **Every heading the publisher cannot place is covered, not only the ones that look foreign.** The publisher cannot - tell "another tracker's marking in a shape I don't know" from "a hand-edited line with the wrong kind of dash" until it - has looked, and both need the same answer. The cheaper answer — publish what you understood, then complain — is the one + tell "another tracker's annotation in a shape I don't know" from "a hand-edited line with the wrong kind of dash" until + it has looked, and both need the same answer. The cheaper answer — publish what you understood, then complain — is the one that creates tickets in a file that may already have been published somewhere else. -The protection lives at every layer that inspects a heading, so a foreign marking reaches the stop rather than being -tidied away before it. The marking format itself does not change, and no migration is needed. +The protection lives at every layer that inspects a heading, so a foreign annotation reaches the stop rather than being +tidied away before it. The annotation format itself does not change, and no migration is needed. **Why this is Phase 2.** Its position is free, and it is here because that is where the source artifact put it. It blocks nothing and nothing blocks it, so moving it would trade traceability against the source for a tidier narrative @@ -219,21 +234,21 @@ same footing as phase 1. **Outcome to demonstrate.** -1. Take a work-items file and publish it to one tracker, so its items are marked as published there. +1. Take a work-items file and publish it to one tracker, so its items are annotated as published there. 2. Point the GitHub publisher at that same file. Today it reports some number published and some number skipped, and the - marked items are in neither count — they are simply gone, with no error. + annotated items are in neither count — they are simply gone, with no error. 3. Ship the phase. 4. Point the GitHub publisher at the same file again. It stops before creating anything, names the specific work items - whose markings it does not recognize and what they appear to be marked by, and creates nothing. + whose annotations it does not recognize and what they appear to be annotated by, and creates nothing. 5. Add the two numbers it reports on an ordinary run to the number it surfaces, and get the number of work items in the file. Nothing is unaccounted for. **Source citations.** -- [Step 2: Close the GitHub publisher's silent hole](feature-specification.md#step-2-close-the-github-publishers-silent-hole) — source position 2. +- [Step 2: Close the GitHub publisher's silent hole](feature-specification.md#step-2-close-the-github-publishers-silent-hole). - [Edge cases and failure modes](feature-specification.md#edge-cases-and-failure-modes) — the work-items rows. - [User interactions](feature-specification.md#user-interactions) — what a format error names. -- [Out of scope](feature-specification.md#out-of-scope) — marking-namespacing is a separate specification, not this +- [Out of scope](feature-specification.md#out-of-scope) — annotation-namespacing is a separate specification, not this phase. **Connects to.** @@ -243,9 +258,9 @@ same footing as phase 1. **Preconditions to verify before starting.** -- Confirm the three work-items publishers all read and mark the same file in the way the source artifact assumes, since - this phase changes only how one of them responds to markings it does not recognize. -- Decide nothing about telling one tracker's markings from another's — that trap is real, is named in the source +- Confirm the three work-items publishers all read and annotate the same file in the way the source artifact assumes, + since this phase changes only how one of them responds to annotations it does not recognize. +- Decide nothing about telling one tracker's annotations from another's — that trap is real, is named in the source artifact, and is deliberately not this phase's. --- @@ -323,7 +338,7 @@ re-freezes. Everything downstream either depends on it or is made honest by it. **Source citations.** -- [Step 3: Teach the release process about every target](feature-specification.md#step-3-teach-the-release-process-about-every-target) — source position 6, moved ahead of the version correction. +- [Step 3: Teach the release process about every target](feature-specification.md#step-3-teach-the-release-process-about-every-target). - [Coordinations](feature-specification.md#coordinations) — the release and the repository, the release and both storefronts, the release and the commit it tags, the check and the release. - [Alternate flows and states](feature-specification.md#alternate-flows-and-states) — what a release does when it meets @@ -344,11 +359,13 @@ re-freezes. Everything downstream either depends on it or is made honest by it. - Confirm [Phase 1](#phase-1) has landed on the branch this work builds from. Without it, the first release after this phase stops rather than repairs. -- Confirm what a release should do when the operator's approved version plan and a target's repair disagree, since the - plan is confirmed before the targets are written and the gate cannot run until after. -- Decide who owns re-running a release after a gate stop, given that recovery costs a separate commit: the release's own - local work is discarded first, then the gap is corrected and committed on its own, then the release is planned from - scratch. +- Settle what a release should do when the operator's approved version plan and a target's repair disagree. The source + artifact does not answer this, and it is the one part of this phase's behavior that is not already decided. See + [OQ-5](#oq-5). +- Confirm the recovery sequence from a gate stop is understood before the gate exists, because the order is what makes it + safe: the release's own local work is discarded first — everything it wrote and everything it created — then the gap is + corrected and committed on its own, then the release is planned from scratch. The source artifact settles this; the + check is that whoever builds the phase has read it. --- @@ -370,7 +387,7 @@ release do it, and the honest reason is not the obvious one. The obvious reason than whenever someone next cuts a release — is contingent on an unknown. If channel two's client resolves from the release tag, the only revision anyone resolves is one a release produced, and a release corrects these numbers itself; the user-facing benefit is then not smaller but nothing, and what survives is that the repository stops publishing numbers it -knows are wrong ([OQ-1](#oq-1)). The reason that does not depend on that unknown is that **this phase is what makes phase +knows are wrong ([OQ-2](#oq-2)). The reason that does not depend on that unknown is that **this phase is what makes phase 6's check green on the day it arrives.** Without it, and absent a release cut in between, the check lands red against eight disagreements, and a signal that is red from birth is one people learn to scroll past. @@ -381,15 +398,15 @@ eight disagreements, and a signal that is red from birth is one people learn to 2. Ship the phase. 3. Line them up again. Every pair agrees. 4. Take a client that has Han installed from channel two and check for updates. An update is offered, which is what this - whole channel has stopped doing — confirming [OQ-2](#oq-2) along the way, since this is the demo that answers it. + whole channel has stopped doing — confirming [OQ-1](#oq-1) along the way, since this is the demo that answers it. 5. Cut a release. Watch the numbers stay corrected rather than re-freeze, which is what phase 3 bought. Step 4 rests on an unverified assumption about how channel two decides an update exists. If it turns out to be wrong, -steps 1 through 3 and 5 still hold and the update claim comes out. See [OQ-2](#oq-2). +steps 1 through 3 and 5 still hold and the update claim comes out. See [OQ-1](#oq-1). **Source citations.** -- [Step 4: Correct the frozen version numbers](feature-specification.md#step-4-correct-the-frozen-version-numbers) — source position 3, moved after the release repair. +- [Step 4: Correct the frozen version numbers](feature-specification.md#step-4-correct-the-frozen-version-numbers). - [Outcome](feature-specification.md#outcome) — the update-availability promise, and its hedge. - [Open items](feature-specification.md#open-items) — items 1 and 2, both of which shape how this phase's benefit is worded. @@ -402,7 +419,7 @@ steps 1 through 3 and 5 still hold and the update claim comes out. See [OQ-2](#o **Preconditions to verify before starting.** - Confirm whether channel two decides update availability from the published version number. The correction is worth - making either way, but the user-facing claim depends on it. See [OQ-2](#oq-2). + making either way, but the user-facing claim depends on it. See [OQ-1](#oq-1). - Confirm no release was cut between [Phase 1](#phase-1) and [Phase 3](#phase-3). If one was, the Linear plugin's channel-one version moved while its new record stayed put, and this phase closes nine gaps rather than eight. @@ -467,8 +484,8 @@ applied to a corrected set rather than a change to the rule. **Source citations.** -- [Step 5: Delete the three untrue dependency declarations](feature-specification.md#step-5-delete-the-three-untrue-dependency-declarations) — source position 4. -- [Step 6: Correct every document the declaration deletion falsifies](feature-specification.md#step-6-correct-every-document-the-declaration-deletion-falsifies) — source position 5, shipped with step 5. +- [Step 5: Delete the three untrue dependency declarations](feature-specification.md#step-5-delete-the-three-untrue-dependency-declarations). +- [Step 6: Correct every document the declaration deletion falsifies](feature-specification.md#step-6-correct-every-document-the-declaration-deletion-falsifies) — shipped with step 5 as one change. - [Coordinations](feature-specification.md#coordinations) — step 5 and step 6 as a single change. - [Deferred (YAGNI)](feature-specification.md#deferred-yagni) — a standing check for decorative dependencies is deliberately not built here. @@ -544,7 +561,7 @@ mode arriving where it actually lives. **Source citations.** -- [Step 7: Turn on the automated check](feature-specification.md#step-7-turn-on-the-automated-check) — source position 7. +- [Step 7: Turn on the automated check](feature-specification.md#step-7-turn-on-the-automated-check). - [Alternate flows and states](feature-specification.md#alternate-flows-and-states) — what a new plugin merged past the check costs. - [Coordinations](feature-specification.md#coordinations) — the check and the pull-request pipeline; the check and the @@ -569,15 +586,33 @@ mode arriving where it actually lives. ## Open Questions {#open-questions} -> Decisions or verifications the team must resolve, ordered by the lowest-numbered phase they shape. None of them blocks -> phase 1 from starting. Cite them as `OQ-N` in follow-up. +> Decisions or verifications the team must resolve. **None of them blocks any phase**, including phase 1. Cite them as +> `OQ-N` in follow-up. > -> All four come from the source artifact's [Open items](feature-specification.md#open-items) and are restated here with -> the phases they touch. The source artifact remains canonical for their full history. +> **OQ-1 through OQ-4 are numbered to match the source artifact's [Open items](feature-specification.md#open-items) 1 +> through 4**, so a number means the same thing in both documents. That distinction is worth keeping: **OQ-5 was raised +> by this outline** and has no source item behind it. The source artifact remains canonical for the history of the first +> four. + +### OQ-1. Does channel two decide update availability from the published version number? {#oq-1} + +**Blocks phase(s).** None. Shapes [Phase 4](#phase-4) and the Outcome's update claim. Source: Open item 1. + +The promise that channel-two users are offered updates again rests entirely on this, and it is unconfirmed. This is the +one open question with a named cost and a named consequence: verifying costs one installed client and one release, and if +it is wrong then phase 4 is still worth doing but its user-facing claim comes out. + +- **Option A — Verify during phase 4's own demo.** Step 4 of that phase's demo is exactly this test. It costs nothing + extra, and it answers the question at the moment the answer becomes useful. +- **Option B — Verify before starting phase 4.** Costs a release ahead of the work. Buys the ability to describe the + benefit correctly before committing to the work, which matters only if the benefit is what justifies the work. +- **Recommendation: Option A.** Phase 4 has a reason that does not depend on this answer at all — it is what makes + phase 6's check green on arrival. So the work is justified either way, and the verification belongs in the demo rather + than ahead of it. Owner: the maintainer, before the update claim is quoted to anyone. -### OQ-1. Which revision does each channel's client resolve from — the default branch, or the latest release tag? {#oq-1} +### OQ-2. Which revision does each channel's client resolve from — the default branch, or the latest release tag? {#oq-2} -**Blocks phase(s).** None. Shapes [Phase 1](#phase-1) and [Phase 4](#phase-4). +**Blocks phase(s).** None. Shapes [Phase 1](#phase-1) and [Phase 4](#phase-4). Source: Open item 2. This is not visible from inside the repository. If clients resolve from the release tag, then phases 1 and 4 reach users at the next release rather than on merge. Both phases are worded to hold either way, so this changes when the fix arrives @@ -592,47 +627,34 @@ phase 1 is a binding constraint before phase 3 regardless. cut anyway, and phase 1's ordering does not depend on the answer. What depends on it is the sentence you put in front of someone, so answer it before you make the promise, not before you start the work. -### OQ-2. Does channel two decide update availability from the published version number? {#oq-2} - -**Blocks phase(s).** None. Shapes [Phase 4](#phase-4) and the Outcome's update claim. - -The source artifact's promise that channel-two users are offered updates again rests entirely on this. It is unconfirmed. -This is the one open question with a named cost and a named consequence: verifying costs one installed client and one -release, and if it is wrong then phase 4 is still worth doing but its user-facing claim comes out. - -- **Option A — Verify during phase 4's own demo.** Step 4 of that phase's demo is exactly this test. It costs nothing - extra, and it answers the question at the moment the answer becomes useful. -- **Option B — Verify before starting phase 4.** Costs a release ahead of the work. Buys the ability to describe the - benefit correctly before committing to the work, which matters only if the benefit is what justifies the work. -- **Recommendation: Option A.** Phase 4 has a reason that does not depend on this answer at all — it is what makes - phase 6's check green on arrival. So the work is justified either way, and the verification belongs in the demo rather - than ahead of it. Owner: the maintainer, before the update claim is quoted to anyone. - ### OQ-3. Should the pull-request check be made required? {#oq-3} -**Blocks phase(s).** None. Shapes what [Phase 6](#phase-6) is worth. +**Blocks phase(s).** None. Shapes what [Phase 6](#phase-6) is worth. Source: Open item 3. -This is a decision, not an unknown. The fact is settled: the default branch is unprotected, no rules apply to it, and the -sole ruleset is disabled and contains no required-check rule, so enabling it as written would not help. No phase in this -outline owns the repository-settings change. +This is a decision, not an unknown. The fact is settled and was verified against the hosting platform during the source +artifact's review: nothing currently protects the branch releases are cut from, and the one protection configuration that +exists is switched off and would not require the check even if it were switched on. No phase in this outline owns the +settings change. - **Option A — Leave the check advisory.** Phase 6 still moves discovery of a gap to the pull request that introduced it, and the release still refuses what matters. The cost is that a contributor can merge past a signal, and for a brand-new plugin that stops the next release rather than merely delaying a fix. -- **Option B — Require the check alone.** Makes the pull-request surface actually block. Worth knowing: the existing - disabled ruleset also demands an approving review, which on a solo-maintained repository would block the maintainer - from merging their own work, and is the likeliest reason it is switched off. Requiring the check alone is the smaller - move. +- **Option B — Require the check, and only the check.** Makes the pull-request surface actually block. Worth knowing: the + protection configuration that already exists would additionally demand an approving review, which on a solo-maintained + repository would stop the maintainer merging their own work, and is the likeliest reason it is switched off. Turning it + on as it stands is the move to avoid; requiring the check alone is the smaller one. - **Recommendation: Option B, after phase 6 has been green for a while.** The advisory version is tolerable, but its tolerability rests on the release refusing to publish something nobody wrote — a protection rather than a repair. Once the check has run green across a few real pull requests and produced no false positives, requiring it alone costs - nothing and closes the gap. Enabling the ruleset as it currently stands is the move to avoid. Owner: the maintainer. + nothing and closes the gap. Owner: the maintainer. -### Carry-over notes +The platform specifics behind this question are in the source artifact's +[technical note T2](artifacts/feature-technical-notes.md), which is where the plain-language rule of this document hands +off — the decision cannot be made without them. ### OQ-4. What happens when plugins of different vintages are installed against each other? {#oq-4} -**Blocks phase(s).** None — carry-over note. +**Blocks phase(s).** None — carry-over note, and the only one. Source: Open item 4. Plugins are installed and updated one at a time, so someone can run a months-old coding plugin against a core plugin updated today, and nothing would notice. An earlier argument that this cannot happen — everything ships from a single @@ -642,6 +664,28 @@ The right fix is not obvious, and this is flagged as a decision the team still o schedules. It is kept here rather than dropped because it has nowhere else to live: no phase depends on it and no follow-up specification carries it. An open question with no home stays with the document that found it. +### OQ-5. What should a release do when the approved version plan and a target's repair disagree? {#oq-5} + +**Blocks phase(s).** None. Shapes [Phase 3](#phase-3). **Raised by this outline — no source item behind it.** + +This one is new, and it comes out of phase 3's own shape rather than from the source artifact. A release asks the +operator to confirm its version plan **before** it writes the targets, and the gate cannot run until **after** they are +written. So there is a window the source artifact does not describe: the operator approves a plan, and then the repair +writes a version onto a record that the plan did not mention, because the plan only covers the plugins the release is +bumping and the repair also touches the ones that merely drifted. + +- **Option A — The repair is silent within the approved plan.** A version the release did not bump is corrected without + re-asking, on the grounds that correcting a drifted record is not a new decision — it is writing the version the plugin + already has. Reported afterward, as phase 3 already commits to. +- **Option B — The plan names every target the release will touch, before it is approved.** The operator approves the + repair as well as the bumps. Costs a longer prompt on every release, most of it saying "and these nine records stay + where they are". +- **Recommendation: Option A.** The correction and the bump are different acts: a bump decides a new version and a repair + publishes one that was already decided. The source artifact draws exactly this line for creation — the release does + what it can derive and stops at what a person must author — and a drifted version is derivable. Option B would ask a + person to approve arithmetic. Worth confirming with the maintainer before phase 3 is built, because it is the one place + phase 3's behavior is not already settled. + --- _End of outline. If you need to cite a specific phase elsewhere, use its `Phase N` number — those numbers are stable for From 25e7bd251d96bb560255b71248d0a1eb06f6b83b Mon Sep 17 00:00:00 2001 From: River Lynn Bailey <river.bailey@testdouble.com> Date: Fri, 17 Jul 2026 07:33:38 -0600 Subject: [PATCH 14/22] feat(han-linear): publish han-linear to the Codex marketplace han-linear is advertised as an opt-in Codex install in the README but was never published there, so 'codex plugin add han-linear@han' errored. Adds the Codex manifest and the marketplace listing entry. The manifest is created at 1.0.2, the version the Claude marketplace already publishes for the plugin, so it is correct on arrival rather than a placeholder. The interface prose is authored from the plugin's description and its work-items-to-linear skill, patterned on han-atlassian as the closest comparable (opt-in, MCP-dependent). Listing entry is placed to preserve the two marketplaces' shared plugin ordering. The han meta-plugin remains absent from Codex, which does not support meta-plugins. Phase 1 of docs/plans/han-publishing-cleanup/build-phase-outline.md --- .agents/plugins/marketplace.json | 12 ++++++++++++ han-linear/.codex-plugin/plugin.json | 28 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 han-linear/.codex-plugin/plugin.json diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 81863116..1b66d0cb 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -100,6 +100,18 @@ }, "category": "Developer Tools" }, + { + "name": "han-linear", + "source": { + "source": "local", + "path": "./han-linear" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Developer Tools" + }, { "name": "han-plugin-builder", "source": { diff --git a/han-linear/.codex-plugin/plugin.json b/han-linear/.codex-plugin/plugin.json new file mode 100644 index 00000000..a946c907 --- /dev/null +++ b/han-linear/.codex-plugin/plugin.json @@ -0,0 +1,28 @@ +{ + "name": "han-linear", + "version": "1.0.2", + "description": "Linear-facing Han skill for publishing work items to a Linear team as issues.", + "author": { + "name": "Test Double", + "url": "https://testdouble.com" + }, + "homepage": "https://github.com/testdouble/han#readme", + "repository": "https://github.com/testdouble/han", + "license": "MIT", + "keywords": ["han", "linear", "issues", "work-items", "planning"], + "skills": "./skills/", + "interface": { + "displayName": "Han Linear", + "shortDescription": "Turn a work-items file into Linear issues, one per slice.", + "longDescription": "Linear-facing Han skill for publishing work items to Linear. Creates one issue per slice in a single target team, resolving states, labels, Projects, and members against the team's real configuration before creating anything. Links dependencies between issues as native blocked-by relations. Requires a configured Linear MCP server.", + "developerName": "Test Double", + "category": "Developer Tools", + "capabilities": ["Skills"], + "websiteURL": "https://github.com/testdouble/han", + "defaultPrompt": [ + "Create Linear issues from these work items.", + "Publish this work-items file to my Linear team.", + "Turn my plan into Linear tickets." + ] + } +} From ebb27f48b7db9f701a2625b480398bffb6d80f04 Mon Sep 17 00:00:00 2001 From: River Lynn Bailey <river.bailey@testdouble.com> Date: Fri, 17 Jul 2026 07:51:07 -0600 Subject: [PATCH 15/22] feat(work-items-to-issues): add check-annotations.sh to account for every slice heading The GitHub publisher drops work items annotated by another tracker: they match neither the publishable pattern nor the already-published-here count, so they are neither created nor reported. A file published to Jira and then pointed at GitHub loses those items with no error and no signal. check-annotations.sh accounts for every slice heading in every file given: publishable here, already published here, or unrecognized. It names every unrecognized heading at once and exits non-zero. A heading is in scope when it carries a symbolic ID, whatever follows it. That admits foreign annotations and hand-edited malformed headings, and never matches preamble prose like '## Shared reference artifacts', which would otherwise stop a valid run. Takes every per-repo file at once: a file published elsewhere is usually annotated across all its repos, so checking one repo at a time would create issues in the clean repos before reaching the annotated one. Not yet wired into the publish path. Phase 2 of docs/plans/han-publishing-cleanup/build-phase-outline.md --- .../scripts/check-annotations.sh | 77 +++++++ test/check-annotations.bats | 193 ++++++++++++++++++ 2 files changed, 270 insertions(+) create mode 100755 han-github/skills/work-items-to-issues/scripts/check-annotations.sh create mode 100755 test/check-annotations.bats diff --git a/han-github/skills/work-items-to-issues/scripts/check-annotations.sh b/han-github/skills/work-items-to-issues/scripts/check-annotations.sh new file mode 100755 index 00000000..fc0be038 --- /dev/null +++ b/han-github/skills/work-items-to-issues/scripts/check-annotations.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Usage: check-annotations.sh <work-items-file>... +# +# Accounts for every slice heading in every file given: each one is publishable +# here, already published here, or unrecognized. Exits non-zero naming every +# unrecognized heading at once, so a run can stop before creating anything +# anywhere. +# +# A heading is in scope when it carries a symbolic ID (`## W-1 ...`), whatever +# follows it. That admits headings annotated by another tracker and headings a +# hand edit left malformed, and it never matches preamble prose like +# `## Shared reference artifacts`, which carries no symbolic ID. +# +# Takes every per-repo file at once because a file published to another tracker +# is usually annotated across all of its repos: checking one repo at a time +# would create issues in the clean repos before reaching the annotated one. + +set -euo pipefail + +[ $# -gt 0 ] || { + echo "usage: check-annotations.sh <work-items-file>..." >&2 + exit 2 +} + +# Symbolic ID: any uppercase prefix plus a number, per the documented format. +sym='[A-Z][A-Z0-9]*-[0-9]+' + +publishable=0 +already=0 +unrecognized=0 +findings=() + +for work_items in "$@"; do + [ -f "$work_items" ] || { + echo "ERROR: work-items file not found: $work_items" >&2 + exit 2 + } + + lineno=0 + while IFS= read -r line || [ -n "$line" ]; do + lineno=$((lineno + 1)) + + # In scope only if the heading carries a symbolic ID. + [[ "$line" =~ ^##[[:space:]]+($sym)([[:space:]]|$) ]] || continue + id="${BASH_REMATCH[1]}" + + if [[ "$line" =~ ^##[[:space:]]+${sym}[[:space:]]+—[[:space:]] ]]; then + publishable=$((publishable + 1)) + elif [[ "$line" =~ ^##[[:space:]]+${sym}[[:space:]]+\(#[0-9]+\)[[:space:]]+—[[:space:]] ]]; then + already=$((already + 1)) + else + unrecognized=$((unrecognized + 1)) + if [[ "$line" =~ \(([^\)]+)\) ]]; then + findings+=("$work_items:$lineno: $id is annotated by ${BASH_REMATCH[1]} — already published to another tracker?") + else + findings+=("$work_items:$lineno: $id heading shape not recognized") + fi + fi + done < "$work_items" +done + +total=$((publishable + already + unrecognized)) +echo "examined $total slice heading(s): $publishable publishable, $already already published here, $unrecognized unrecognized" + +if [ "$unrecognized" -gt 0 ]; then + { + echo + echo "ERROR: $unrecognized slice heading(s) could not be placed. Nothing has been created." + for finding in "${findings[@]}"; do + echo " $finding" + done + echo + echo "Publishing a file that another tracker already published would duplicate that work." + echo "Resolve each heading above, then re-run." + } >&2 + exit 1 +fi diff --git a/test/check-annotations.bats b/test/check-annotations.bats new file mode 100755 index 00000000..b73a145e --- /dev/null +++ b/test/check-annotations.bats @@ -0,0 +1,193 @@ +#!/usr/bin/env bats +# +# check-annotations.sh accounts for every slice heading in a work-items file: +# each one is publishable here, already published here, or surfaced. Nothing +# is silently passed over. +# +# A heading is in scope when it carries a symbolic ID (`## W-1 ...`), whatever +# follows it. Preamble prose headings never match that shape, so they cannot +# cause a false stop. + +setup() { + CHECK="${BATS_TEST_DIRNAME}/../han-github/skills/work-items-to-issues/scripts/check-annotations.sh" + TMP="$(mktemp -d)" +} + +teardown() { + rm -rf "$TMP" +} + +# --- clean files pass --------------------------------------------------------- + +@test "passes a file whose slices are all unannotated" { + cat > "$TMP/wi.md" <<'EOF' +# Work Items — Example + +## W-1 — First slice +body +## W-2 — Second slice +body +EOF + run "$CHECK" "$TMP/wi.md" + [ "$status" -eq 0 ] +} + +@test "passes a file whose slices were already published here" { + cat > "$TMP/wi.md" <<'EOF' +## W-1 (#12) — Already published +body +## W-2 — Not yet published +body +EOF + run "$CHECK" "$TMP/wi.md" + [ "$status" -eq 0 ] +} + +@test "ignores preamble headings that carry no symbolic ID" { + cat > "$TMP/wi.md" <<'EOF' +# Work Items — Example + +Work items are numbered W-N for cross-reference only. + +## Shared reference artifacts + +- [API contract](./contract.md#envelope) + +## W-1 — First slice +body +EOF + run "$CHECK" "$TMP/wi.md" + [ "$status" -eq 0 ] +} + +# --- foreign annotations are surfaced ----------------------------------------- + +@test "stops on a heading annotated by another tracker" { + cat > "$TMP/wi.md" <<'EOF' +## W-1 — Fine +body +## W-2 (ACME-142) — Published to another tracker +body +EOF + run "$CHECK" "$TMP/wi.md" + [ "$status" -ne 0 ] + [[ "$output" == *"W-2"* ]] + [[ "$output" == *"ACME-142"* ]] +} + +@test "names what the heading appears to be annotated by" { + cat > "$TMP/wi.md" <<'EOF' +## W-1 (ENG-99) — Published to a different tracker +body +EOF + run "$CHECK" "$TMP/wi.md" + [ "$status" -ne 0 ] + [[ "$output" == *"ENG-99"* ]] +} + +@test "stops on a heading it cannot place even when it looks like nobody else's" { + cat > "$TMP/wi.md" <<'EOF' +## W-1 - Hand-edited with the wrong dash +body +EOF + run "$CHECK" "$TMP/wi.md" + [ "$status" -ne 0 ] + [[ "$output" == *"W-1"* ]] +} + +@test "names every unrecognized heading, not just the first" { + cat > "$TMP/wi.md" <<'EOF' +## W-1 (ACME-1) — First foreign +body +## W-2 — Fine +body +## W-3 (ENG-2) — Second foreign +body +## W-4 - Wrong dash +body +EOF + run "$CHECK" "$TMP/wi.md" + [ "$status" -ne 0 ] + [[ "$output" == *"W-1"* ]] + [[ "$output" == *"W-3"* ]] + [[ "$output" == *"W-4"* ]] +} + +@test "stops on a file published entirely to another tracker" { + # The case the whole phase exists for: every heading is foreign-annotated, so + # there is no recognizable unannotated slice to anchor on. This must surface, + # not read as a file with nothing in it. + cat > "$TMP/wi.md" <<'EOF' +# Work Items — Example + +## W-1 (ACME-1) — First +body +## W-2 (ACME-2) — Second +body +EOF + run "$CHECK" "$TMP/wi.md" + [ "$status" -ne 0 ] + [[ "$output" == *"W-1"* ]] + [[ "$output" == *"W-2"* ]] +} + +# --- every heading is accounted for ------------------------------------------- + +@test "reports counts that add up to every slice heading in the file" { + cat > "$TMP/wi.md" <<'EOF' +## W-1 — Publishable +body +## W-2 (#7) — Already published here +body +## W-3 (ACME-3) — Foreign +body +EOF + run "$CHECK" "$TMP/wi.md" + [ "$status" -ne 0 ] + [[ "$output" == *"1 publishable"* ]] + [[ "$output" == *"1 already published"* ]] + [[ "$output" == *"1 unrecognized"* ]] +} + +# --- multi-file: nothing publishes anywhere ----------------------------------- + +@test "checks every file it is given, not just the first" { + cat > "$TMP/a.md" <<'EOF' +## W-1 — Clean repo file +body +EOF + cat > "$TMP/b.md" <<'EOF' +## W-2 (ACME-9) — Dirty repo file +body +EOF + run "$CHECK" "$TMP/a.md" "$TMP/b.md" + [ "$status" -ne 0 ] + [[ "$output" == *"W-2"* ]] + [[ "$output" == *"b.md"* ]] +} + +@test "passes only when every file it is given is clean" { + cat > "$TMP/a.md" <<'EOF' +## W-1 — Clean +body +EOF + cat > "$TMP/b.md" <<'EOF' +## W-2 (#3) — Also clean +body +EOF + run "$CHECK" "$TMP/a.md" "$TMP/b.md" + [ "$status" -eq 0 ] +} + +# --- input handling ----------------------------------------------------------- + +@test "fails loudly when a file does not exist" { + run "$CHECK" "$TMP/nope.md" + [ "$status" -ne 0 ] + [[ "$output" == *"not found"* ]] +} + +@test "requires at least one file" { + run "$CHECK" + [ "$status" -ne 0 ] +} From bfd540de87357def2dc68617f0b88e5a912eaded Mon Sep 17 00:00:00 2001 From: River Lynn Bailey <river.bailey@testdouble.com> Date: Fri, 17 Jul 2026 07:52:39 -0600 Subject: [PATCH 16/22] feat(work-items-to-issues): examine the whole file before creating anything create-issues.sh walked the file creating one issue at a time, so a heading it could not place was simply never matched: neither created nor counted. An annotation late in the file was also preceded by issues already created. It now runs check-annotations.sh over the file before anything reaches the target repo, ahead of label creation too, since creating a label is itself a change to the repo. A heading it cannot place stops the run and is named. Tests stub gh on PATH and assert nothing is sent. --- .../scripts/create-issues.sh | 13 ++ test/create-issues-guard.bats | 127 ++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100755 test/create-issues-guard.bats diff --git a/han-github/skills/work-items-to-issues/scripts/create-issues.sh b/han-github/skills/work-items-to-issues/scripts/create-issues.sh index 6c6d07be..ae5b3a75 100755 --- a/han-github/skills/work-items-to-issues/scripts/create-issues.sh +++ b/han-github/skills/work-items-to-issues/scripts/create-issues.sh @@ -14,6 +14,10 @@ # are optional. # # Idempotent: slices whose heading already includes `(#NNN)` are skipped. +# +# Accounts for every slice heading before creating anything: each one is +# publishable, already published here, or surfaced by check-annotations.sh, +# which stops the run. Nothing is silently passed over. set -euo pipefail @@ -33,6 +37,15 @@ done [ -f "$WORK_ITEMS" ] || { echo "work-items file not found: $WORK_ITEMS" >&2; exit 1; } +DIR="$(cd "$(dirname "$0")" && pwd)" + +# Account for every slice heading before creating anything — including the +# label, which is itself a change to the target repo. A heading annotated by +# another tracker, or left malformed by a hand edit, stops the run here rather +# than being passed over silently. Examining the whole file first is what keeps +# an annotation late in the file from being preceded by issues already created. +"$DIR/check-annotations.sh" "$WORK_ITEMS" + if [ -n "$LABEL" ]; then # Create the label without `--force`, which would reset an existing label's # color/description. A create that fails on an existing label leaves it diff --git a/test/create-issues-guard.bats b/test/create-issues-guard.bats new file mode 100755 index 00000000..91ccfd12 --- /dev/null +++ b/test/create-issues-guard.bats @@ -0,0 +1,127 @@ +#!/usr/bin/env bats +# +# create-issues.sh examines the whole file before creating the first issue. +# A heading it cannot place stops the run before anything reaches the tracker, +# so an annotation late in the file is never preceded by issues already created. +# +# `gh` is stubbed on PATH: these tests assert what the script would send, and +# must never reach the network. + +setup() { + SCRIPT="${BATS_TEST_DIRNAME}/../han-github/skills/work-items-to-issues/scripts/create-issues.sh" + TMP="$(mktemp -d)" + GH_LOG="$TMP/gh-calls.log" + export GH_LOG + + # Stub gh: log every invocation, and answer `issue create` with a plausible URL. + mkdir -p "$TMP/bin" + cat > "$TMP/bin/gh" <<'EOF' +#!/usr/bin/env bash +echo "$*" >> "$GH_LOG" +if [ "${1:-}" = "issue" ] && [ "${2:-}" = "create" ]; then + echo "https://github.com/acme/repo/issues/501" +fi +EOF + chmod +x "$TMP/bin/gh" + PATH="$TMP/bin:$PATH" + export PATH + : > "$GH_LOG" +} + +teardown() { + rm -rf "$TMP" +} + +gh_call_count() { + if [ -s "$GH_LOG" ]; then wc -l < "$GH_LOG" | tr -d ' '; else echo 0; fi +} + +@test "creates nothing when a heading is annotated by another tracker" { + cat > "$TMP/wi.md" <<'EOF' +## W-1 — Would publish fine +body +## W-2 (ACME-142) — Published to another tracker +body +EOF + run "$SCRIPT" "$TMP/wi.md" acme/repo + [ "$status" -ne 0 ] + [ "$(gh_call_count)" -eq 0 ] +} + +@test "the file is left untouched when the run stops" { + cat > "$TMP/wi.md" <<'EOF' +## W-1 — Would publish fine +body +## W-2 (ACME-142) — Foreign +body +EOF + before="$(cat "$TMP/wi.md")" + run "$SCRIPT" "$TMP/wi.md" acme/repo + [ "$status" -ne 0 ] + [ "$(cat "$TMP/wi.md")" = "$before" ] +} + +@test "a foreign annotation last in the file still stops the first item being created" { + # The ordering the guard exists for: without examine-first, W-1 and W-2 would + # already be issues by the time the publisher reached W-3. + cat > "$TMP/wi.md" <<'EOF' +## W-1 — First +body +## W-2 — Second +body +## W-3 (ENG-7) — Foreign, last in the file +body +EOF + run "$SCRIPT" "$TMP/wi.md" acme/repo + [ "$status" -ne 0 ] + [ "$(gh_call_count)" -eq 0 ] +} + +@test "names the offending heading when it stops" { + cat > "$TMP/wi.md" <<'EOF' +## W-9 (ACME-3) — Foreign +body +EOF + run "$SCRIPT" "$TMP/wi.md" acme/repo + [ "$status" -ne 0 ] + [[ "$output" == *"W-9"* ]] + [[ "$output" == *"ACME-3"* ]] +} + +@test "still publishes a clean file" { + cat > "$TMP/wi.md" <<'EOF' +## W-1 — A clean slice +body +EOF + run "$SCRIPT" "$TMP/wi.md" acme/repo + [ "$status" -eq 0 ] + grep -q "issue create" "$GH_LOG" + grep -q '^## W-1 (#501) — A clean slice$' "$TMP/wi.md" +} + +@test "still skips a file already published here without creating anything" { + cat > "$TMP/wi.md" <<'EOF' +## W-1 (#12) — Already published here +body +EOF + run "$SCRIPT" "$TMP/wi.md" acme/repo + [ "$status" -eq 0 ] + run grep -c "issue create" "$GH_LOG" + [ "$output" -eq 0 ] +} + +@test "does not stop on preamble headings that carry no symbolic ID" { + cat > "$TMP/wi.md" <<'EOF' +# Work Items — Example + +## Shared reference artifacts + +- [contract](./c.md) + +## W-1 — A clean slice +body +EOF + run "$SCRIPT" "$TMP/wi.md" acme/repo + [ "$status" -eq 0 ] + grep -q "issue create" "$GH_LOG" +} From 127c387f06c1823456fadc050dab81bf96654244 Mon Sep 17 00:00:00 2001 From: River Lynn Bailey <river.bailey@testdouble.com> Date: Fri, 17 Jul 2026 07:55:37 -0600 Subject: [PATCH 17/22] docs(work-items-to-issues): stop the repair pass tidying away another tracker's annotation The repair pass accepted only the publishable and already-published-here heading shapes, so a Jira- or Linear-annotated heading failed the heading shape check and landed in the malformed-heading bucket, whose documented remedy is to propose the corrected shape. Continue with fills would then strip the annotation and publish the items to GitHub as duplicates of work already tracked elsewhere -- the annotation never reaching a gate. Another tracker's annotation is now its own category, never repaired, and offers only Stop. The malformed-heading rule now covers what it was always for: a heading nobody else annotated. Step 5 accounts for every slice heading across every per-repo file at once before any repo is published to, so nothing publishes anywhere while one file is annotated elsewhere. The format reference now documents all three buckets and why the scan keys on the symbolic ID rather than the heading shape. --- .../skills/work-items-to-issues/SKILL.md | 30 +++++++++++++++++-- .../references/work-items-file-format.md | 27 +++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/han-github/skills/work-items-to-issues/SKILL.md b/han-github/skills/work-items-to-issues/SKILL.md index 003ef227..e990b2ae 100644 --- a/han-github/skills/work-items-to-issues/SKILL.md +++ b/han-github/skills/work-items-to-issues/SKILL.md @@ -70,6 +70,9 @@ Check the work-items file against the format invariants in - **Heading shape.** Every slice heading matches `## <SYM-N> — <title>` with an em-dash separator (already-published headings annotated as `## <SYM-N> (#NNN) — <title>` are valid too). +- **Annotated by another tracker.** A slice heading carrying an annotation that is not `(#NNN)` — `## <SYM-N> (ACME-142) + — <title>`, `## <SYM-N> (ENG-99) — <title>` — means this file was already published somewhere else. This is a distinct + category from a malformed heading and is **never repaired**. See the repair rules below. - **`Depends on` line.** Literal bold marker `**Depends on.**`, trailing period, `None.` or comma-separated SYMs. - **Within-repo blockers.** Every SYM named in a `Depends on` line maps to the same target repo as the dependent slice (under the map from Step 2). @@ -86,7 +89,15 @@ When a check fails, attempt evidence-based repair. Pull evidence from the source referenced in its intro, the feature spec in the same folder, sibling files in the plan folder, and the target repo's ADRs / coding standards / docs: -- **Malformed heading** — propose the corrected shape based on the surrounding text. Cite the line number. +- **Annotated by another tracker** — **never repair this.** Do not propose stripping the annotation, and do not treat it + as a malformed heading to be corrected into shape. The annotation is evidence that these work items already exist + somewhere else, and tidying it away here would publish duplicates of work that is already tracked. Stop and report + every such heading with the annotation it carries, so the user decides whether this file was already published + elsewhere. Only the user resolves this category. +- **Malformed heading** — propose the corrected shape based on the surrounding text. Cite the line number. This covers a + heading nobody else annotated: a hand edit with the wrong dash, a missing separator, a mistyped symbolic ID. If the + heading carries an annotation that is not `(#NNN)`, it belongs to the category above instead — a heading you cannot + parse may be another tracker's annotation in a shape you do not know, and the two need different answers. - **Missing `Depends on` line** — propose `None.` if no blockers are evident in the slice's prose. Cite the absence. - **Cross-repo `Depends on`** — propose moving the relationship to the cross-repo work-order prose at the top of the file and replacing the line with `None.` or remaining within-repo SYMs. Cite the SYM→repo map entries that prove the @@ -112,6 +123,12 @@ Then give the user three actions: - **Correct the fills** — user provides the right values; apply those and proceed. - **Stop** — exit without publishing. User edits the file by hand and re-runs. +**A heading annotated by another tracker is the exception: it has no fills, so only Stop is offered.** Report every such +heading and what it appears to be annotated by, tell the user their file looks like it was already published to that +tracker, and exit without publishing. Do not offer to strip the annotation and continue — that is the one "repair" that +silently duplicates work the user already has. If they genuinely want these items in GitHub as well, they say so and +edit the file themselves; that is a decision to make on purpose, not a default. + If validation passes with no findings, proceed silently to Step 4. ### 4. Show the SYM→repo map for confirmation @@ -140,6 +157,13 @@ source `work-items.md`. The per-repo file is a filtered view of the source — i The source `work-items.md` is not modified by the publish step. The per-repo files are what carry the `(#NNN)` issue-number annotations after publishing. +Once every per-repo file is written, account for every slice heading across all of them at once by running +`${CLAUDE_SKILL_DIR}/scripts/check-annotations.sh <per-repo-file-1> <per-repo-file-2> ...` with every per-repo file in +one invocation. If it exits non-zero, report what it names and stop — publish nothing, to any repo. A file that another +tracker already published is usually annotated across every repo it touches, so checking one repo at a time would create +issues in the clean repos before reaching the annotated one. Nothing publishes anywhere until every file is accounted +for. + ### 6. Publish each per-repo file to GitHub For each per-repo file, publish it by running @@ -162,7 +186,9 @@ The wrapper runs three idempotent scripts in order: PR merges — the issues are still created immediately. Overwrites existing files cleanly and, on re-run, reuses the assets branch and open PR — but only a branch it created for this feature (one already carrying the feature's `issue-assets/<feature-slug>/` tree); a same-named branch it does not own is refused rather than committed onto. -2. **`scripts/create-issues.sh`** — creates one GitHub issue per `## <SYM-N>` slice in file order (blocker-first), +2. **`scripts/create-issues.sh`** — accounts for every slice heading in the file before creating anything (via + `scripts/check-annotations.sh`, the same check Step 5 runs across every per-repo file), so a heading it cannot place + stops the run rather than being passed over. Then creates one GitHub issue per `## <SYM-N>` slice in file order (blocker-first), unassigned and unlabeled by default. When `--label <name>` is passed, it applies that label to every issue, creating it on the repo only if it does not already exist (an existing label's color and description are left intact); when `--assignee <user>` is passed, it assigns each issue to that user. Captures each returned issue number and rewrites diff --git a/han-github/skills/work-items-to-issues/references/work-items-file-format.md b/han-github/skills/work-items-to-issues/references/work-items-file-format.md index 80e7c288..319a086b 100644 --- a/han-github/skills/work-items-to-issues/references/work-items-file-format.md +++ b/han-github/skills/work-items-to-issues/references/work-items-file-format.md @@ -87,3 +87,30 @@ source file does not. The slice-body invariants are documented in [issue-template.md](./issue-template.md). The per-repo file's preamble (title, intro, cross-repo prose, shared references) is for the human reviewer — the scripts ignore everything before the first `## <SYM-N>` heading. + +## Every slice heading is accounted for + +`scripts/check-annotations.sh` classifies every heading that carries a symbolic ID, and every one lands in exactly one +of three buckets: + +| Heading | Meaning | What happens | +| -------------------------------- | --------------------------- | ------------------------- | +| `## W-1 — <title>` | Publishable here | An issue is created | +| `## W-1 (#12) — <title>` | Already published here | Skipped and counted | +| `## W-1 (ACME-142) — <title>` | Published to another tracker | Surfaced; the run stops | +| `## W-1 - <title>` (wrong dash) | Cannot be placed | Surfaced; the run stops | + +A heading is in scope when it carries a symbolic ID, **whatever follows it**. That is what lets the check see another +tracker's annotation and a hand-edited heading alike, and it is why preamble prose (`## Shared reference artifacts`) +never triggers a stop: it carries no symbolic ID. + +Two properties make the promise real rather than circular: + +- **The whole file is examined before the first issue is created**, so an annotation late in the file is never preceded + by issues already created. The check runs ahead of label creation too, since creating a label also changes the target + repo. +- **Every per-repo file is examined before any repo is published to.** A file another tracker published is usually + annotated across all of its repos, so a per-file check would create issues in the clean repos first. + +A heading annotated by another tracker is never repaired into shape. Stripping the annotation would publish duplicates +of work that already exists somewhere else. From 3ed3e914b11248af9db2e15274f92f58d53479e5 Mon Sep 17 00:00:00 2001 From: River Lynn Bailey <river.bailey@testdouble.com> Date: Fri, 17 Jul 2026 07:57:21 -0600 Subject: [PATCH 18/22] docs(work-items-to-issues): document the accounted-for promise in the long-form doc The canonical operator-facing doc described a publisher that silently dropped items another tracker had published. It now states the promise every run keeps -- each item published, skipped-and-counted, or surfaced -- and the two properties that make it real: the whole file is examined before the first issue, and every per-repo file before any repo. The 'three scripts behind one wrapper' count is unchanged and still true; check-annotations.sh is called by create-issues.sh, not added to the wrapper. --- .../skills/han-github/work-items-to-issues.md | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/docs/skills/han-github/work-items-to-issues.md b/docs/skills/han-github/work-items-to-issues.md index bf644ded..78d4e405 100644 --- a/docs/skills/han-github/work-items-to-issues.md +++ b/docs/skills/han-github/work-items-to-issues.md @@ -41,6 +41,11 @@ and _how_ to use the skill. For what the skill does internally, read the skill d without evidence are surfaced as gaps, not applied silently. - **Idempotent publish.** The publish pipeline resumes cleanly after a partial failure. Items already annotated with their issue number are skipped, and screenshot uploads overwrite in place. +- **Every item is accounted for.** In every run, each item in the file is published, skipped-and-counted, or surfaced. + An item another tracker already published is surfaced rather than passed over, and the run stops before creating + anything — including in the other repos, since a file published elsewhere is usually annotated across all of them. + The annotation is never stripped to make the item publishable: doing so would duplicate work you already track + somewhere else. - **No label, no assignee by default.** Issues are created unlabeled and unassigned. You can pass an optional `--label` and `--assignee` when you want them. @@ -159,14 +164,16 @@ The skill walks a six-step process: 3. **Validate the format with evidence-based repair.** Check the file against the format invariants the publish scripts depend on (heading shape, `Depends on` syntax, within-repo blockers, screenshot URL scheme, references present, no process artifacts). When a check fails, propose a fix backed by a concrete source and give you three actions: - continue with the fills, correct them, or stop. + continue with the fills, correct them, or stop. An item another tracker already published is the exception — it has + no fill, so the skill reports what it found and stops. 4. **Show the item-to-repo map for confirmation.** Present the table and wait. Nothing is written or created until you confirm. 5. **Write the per-repo work-items files.** For each target repo, write a filtered `<repo-name>.work-items.md` next to - the source. This is the file the publish scripts read. + the source. This is the file the publish scripts read. Then account for every item across all of those files at once; + if any item cannot be placed, nothing publishes to any repo. 6. **Publish each per-repo file.** Run the publish pipeline, which runs three idempotent scripts in order. First, upload - the screenshots into the target repo. Then create one issue per item, annotating each heading with its `(#NNN)`. - Finally, post the within-repo `blocked_by` links. + the screenshots into the target repo. Then examine the whole file and create one issue per item, annotating each + heading with its `(#NNN)`. Finally, post the within-repo `blocked_by` links. The publish pipeline is three scripts behind one wrapper. @@ -176,9 +183,11 @@ request when the default branch is protected. The fallback runs entirely through is never touched (no local git is involved). On re-run, it reuses the assets branch only when that branch already carries this feature's `issue-assets/<feature-slug>/` tree; it refuses a same-named branch it did not create. -`create-issues.sh` creates one issue per item in file order, captures the returned number, and rewrites the heading in -place so the next script can resolve symbolic IDs to issue numbers. It applies a label and an assignee only when you -pass them. +`create-issues.sh` accounts for every item in the file before it creates anything, then creates one issue per item in +file order, captures the returned number, and rewrites the heading in place so the next script can resolve symbolic IDs +to issue numbers. It applies a label and an assignee only when you pass them. Examining the whole file first is what +keeps an item another tracker published, sitting late in the file, from being preceded by issues already created — and +the check runs ahead of label creation too, since a label is also a change to your repo. `link-blockers.sh` reads the recorded numbers and posts a native `blocked_by` relationship per blocker. It errors out if a `Depends on` line names an item that is not in the same repo, because a cross-repo dependency belongs in the From 1e1d82d54de5392962d6af408ef8549bec434cb1 Mon Sep 17 00:00:00 2001 From: River Lynn Bailey <river.bailey@testdouble.com> Date: Fri, 17 Jul 2026 08:12:48 -0600 Subject: [PATCH 19/22] docs(plans): add phase 3 discovery notes for implementation planning Records the verified state: exactly 8 plugins' channel-two records disagree with channel one (measured, matching the spec), the channel-two listing has never been touched by a release commit, and the plugin set is cleanly derivable from 11 directories. Names the release skill's exact defect sites and the contributor-guide line phase 3 must correct. --- .../artifacts/.discovery-notes.md | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/plans/han-publishing-cleanup/artifacts/.discovery-notes.md diff --git a/docs/plans/han-publishing-cleanup/artifacts/.discovery-notes.md b/docs/plans/han-publishing-cleanup/artifacts/.discovery-notes.md new file mode 100644 index 00000000..d2f634e1 --- /dev/null +++ b/docs/plans/han-publishing-cleanup/artifacts/.discovery-notes.md @@ -0,0 +1,112 @@ +# Discovery notes — phase 3 (teach the release process about every target) + +Written by `han-planning:plan-implementation` Step 2. Single source of project context for the specialist team. **Read +this first; do not re-grep for what is already here.** Search further only for what your domain needs that is not +covered. + +Scope: phase 3 of [../build-phase-outline.md](../build-phase-outline.md). Phases 1 and 2 have landed. + +## Tech stack + +- **The whole suite is Markdown + Bash.** No application runtime, no database, no service. Plugins are directories of + skill/agent Markdown plus a few shell scripts. +- **Test runner:** Bats (`npm test` → `bats --recursive test/`). `test/` currently holds `sanity.bats`, + `check-annotations.bats`, `create-issues-guard.bats` (the latter two added by phase 2). +- **Lint:** `prek` (`npm run lint`) running Prettier (JSON/YAML/JS/Markdown-excluded), ShellCheck, and hygiene hooks. + Config: `.pre-commit-config.yaml`. **Hooks exclude `^(docs/plans/|docs/research/|…)`,** so nothing under `docs/plans/` + is linted. +- **CI:** `.github/workflows/ci.yml` — `lint` and `test` jobs, both on `pull_request` and pushes to `main`. This is + where phase 6's check lands later; phase 3 does not touch it. +- **Tooling required by the release:** `gh`, `jq`, clean git checkout. + +## The four targets, concretely + +| Target | Path | Carries a version? | Written by a release today? | +| --------------------------------- | -------------------------------- | ------------------ | --------------------------- | +| Channel-one listing | `.claude-plugin/marketplace.json` | **yes**, per plugin | **yes** (Step 4) | +| Channel-one per-plugin record | `{dir}/.claude-plugin/plugin.json` | yes | **yes** (Step 4) | +| Channel-two listing | `.agents/plugins/marketplace.json` | **no** | **never** | +| Channel-two per-plugin record | `{dir}/.codex-plugin/plugin.json` | yes | **never** | + +Three version records per plugin, not two — channel one's listing carries a version alongside its per-plugin record. + +## Verified state (measured, not assumed) + +- **Exactly 8 plugins' channel-two records disagree with channel one.** Measured this session: + `han-atlassian 2.2.0/1.1.0`, `han-coding 2.6.0/1.0.0`, `han-core 2.2.1/1.2.0`, `han-feedback 2.0.0/1.1.1`, + `han-github 2.2.2/1.2.0`, `han-planning 2.0.4/1.0.0`, `han-plugin-builder 2.0.5/1.1.0`, `han-reporting 2.1.1/1.0.1`. + Matches the spec's claim exactly. These are phase 4's eight. +- **`han-communication` agrees** (1.0.0 both) — new enough never to have drifted. +- **`han-linear` agrees** (1.0.2 both) — phase 1 created its channel-two record at channel one's version, exactly as the + spec predicted, so it is not a ninth gap. +- **`han` (the bundle) has no channel-two record and no channel-two listing entry** — the permanent named exception. +- **Channel one's listing and per-plugin records agree for every plugin today**, because a release writes both. +- **`git log -- .agents/plugins/marketplace.json` shows no `chore(release)` commit, ever.** Only feature commits. The + channel-two records were last touched by a rename commit (`d94daa2`). This is the drift's mechanism, confirmed in + history. +- **27 tags exist**; the spec's "eleven releases" refers to the span since the drift began, not the total. +- **The plugin set is cleanly derivable:** exactly 11 directories match `*/.claude-plugin/plugin.json`, and all 11 are + plugins. No false positives. + +## Code touch points + +- **`.claude/skills/han-release/SKILL.md`** — the release process. Repo-local skill, **prose only, no scripts**. This is + the primary artifact phase 3 changes. Relevant structure: + - _Project Context_ (frontmatter `!` commands) — **enumerates plugins via + `jq -r '.plugins[] | …' .claude-plugin/marketplace.json`**. This is the "takes its plugin list from a target it also + writes" defect, verbatim. + - _Step 1.2_ — hard stop on a dirty working tree. This is why a gate stop costs a separate commit. + - _Step 1.3_ — non-blocking note when cutting from a non-default branch; the release permits it. + - _Step 3d_ — **the single mandatory plan-confirmation gate, via `AskUserQuestion`. Runs BEFORE Step 4 writes.** This + is why a refusal always lands after someone approved a plan. + - _Step 4_ — applies versions to **two** targets, and **only for plugins whose `target != current`** ("Skip any plugin + whose `target == current`"). This is why a drifted record stays drifted no matter how many releases run. + - _Step 8.1_ — commits `CHANGELOG.md`, `.claude-plugin/marketplace.json`, and every changed + `{source}/.claude-plugin/plugin.json`. Two of four targets. + - _Steps 8.2–8.3, 9_ — tag, push, publish. The irreversible actions the gate must precede. + - Vocabulary section defines **parent** (name == marketplace `name`, normally `han`) and **children**. The bundle + exception has an existing name here. +- **`han-github/skills/work-items-to-issues/scripts/check-annotations.sh`** — phase 2's check. Useful precedent for a + check script's shape: multi-input, names every finding at once, exits non-zero, one bearer with two callers. +- **`test/*.bats`** — precedent for testing a script by stubbing an external CLI on `PATH` (phase 2 stubs `gh`). +- **`docs/semantic-versioning.md`** — the bump-level classification the release cites. + +## Documents phase 3 must correct (the release-procedure docs it falsifies) + +- **`CONTRIBUTING.md:138`** — "When a change adds, removes, or moves a skill between plugins, update the marketplace + registry at `.claude-plugin/marketplace.json`". **Names one target where there are four.** Confirmed live. Also + `CONTRIBUTING.md:157` ("Update the marketplace registry at …") in the Adding-a-skill steps. +- The release skill's own prose describes a two-target release throughout. + +## Gaps — searched for, not found + +- **No ADRs.** No `docs/adr/`, no `docs/architecture/decisions/`. No architectural decision record constrains this work. +- **No repo coding standards.** No `docs/coding-standards/`, no `.github/CODING_STANDARDS.md`. Bash standards come from a + **user-level rule** pointing at Betterment's "The Book" (`pnpm dlx @betterment/the-book-cli rules bash`) — key points: + `#!/usr/bin/env bash`, `set -euo pipefail`, ShellCheck clean, long options, `mktemp` + traps, BSD/GNU portability + (macOS local, Ubuntu CI). +- **No existing implementation plan** in `docs/plans/` to use as format precedent — this is the first. +- **No release runbook** beyond the skill itself. +- **No branch protection.** Verified during spec review (spec technical note T2): the default branch is unprotected and + the sole ruleset is disabled. Relevant to phase 6, not phase 3. + +## Constraints inherited from the source spec (do not re-open) + +These are behavioral commitments the spec settled across three review rounds. Honor them; raise a contradiction as a +finding rather than silently overriding. + +- The release **creates what it can derive and stops at what must be authored** (D36). Creation reaches **channel two's + listing entry only**. A per-plugin record on either channel is the plugin's authored storefront presence — a release + that created one would be inventing prose. +- **Five gaps a release refuses:** a plugin with no record on a channel it belongs in; a listing naming a plugin not in + the repository; a record or listing it cannot read; a version value it cannot make sense of; a plugin whose publishing + version it cannot determine. +- **The gate runs after all four targets are written and before anything irreversible** (D24), and names every gap at + once, not the first (D12). +- **Every target the release writes travels into the commit it tags** (D37). +- **The release runs the check rather than restating it** (D14) — one rule, one bearer, shared with phase 6. +- **The bundle is exempt on channel two**: not flagged, not asked to agree, and **not created** (D6). It is *not* exempt + from agreement between channel one's two records. +- **A release reports what it created and what it changed** (D39) — changed, not written, since after the repair a + release writes every record every run. +- The publishing version is read from the plugin's **own channel-one record**, never from a listing (D31). From e29fdf68db3b1ee6e1a1dfa183de086f01effcba Mon Sep 17 00:00:00 2001 From: River Lynn Bailey <river.bailey@testdouble.com> Date: Fri, 17 Jul 2026 08:40:26 -0600 Subject: [PATCH 20/22] docs(plans): correct the phase 3 discovery notes after review Two corrections from the specialist round, both re-verified live. The contributor-guide diagnosis was wrong and was labelled 'Confirmed live'. CONTRIBUTING.md:138 and :157 are about moving a SKILL between plugins, which touches none of the four publishing targets, so phase 3 does not falsify them and D7 excludes them. Rewriting them to name four targets would have made the guide wrong in a new way. The real gap is that CONTRIBUTING.md has no 'Adding a plugin' section at all and no contributor-facing doc mentions channel two's files -- so phase 3 adds a section rather than correcting a passage. Adds the measured create-path field list: channel two's listing entries carry no version field, so 'created at the version it is publishing' is vacuous for the only thing a release creates. Records the measured justification for D36's boundary -- channel two's listing entry is the only target with no authored field in it. --- .../artifacts/.discovery-notes.md | 46 +++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/docs/plans/han-publishing-cleanup/artifacts/.discovery-notes.md b/docs/plans/han-publishing-cleanup/artifacts/.discovery-notes.md index d2f634e1..17bd49b0 100644 --- a/docs/plans/han-publishing-cleanup/artifacts/.discovery-notes.md +++ b/docs/plans/han-publishing-cleanup/artifacts/.discovery-notes.md @@ -30,6 +30,31 @@ Scope: phase 3 of [../build-phase-outline.md](../build-phase-outline.md). Phases Three version records per plugin, not two — channel one's listing carries a version alongside its per-plugin record. +### The create-path's field list, measured (junior-developer finding JD-001) + +Verified with `jq` this session: + +- **Channel-two listing entry keys are exactly `[category, name, policy, source]`. There is no `version` field** — + `[.plugins[] | has("version")] | unique` → `[false]`. So the spec's "a created entry … carries the version the release + is publishing" (D31) is true of the *records* it updates and **vacuous for the one thing it creates**. A created entry + names no version, and D39's report line for a creation cannot name one either. +- `policy` and `category` are **100% uniform** across all existing entries (`[{policy, category}] | unique | length` → 1): + `policy: {installation: "AVAILABLE", authentication: "ON_INSTALL"}`, `category: "Developer Tools"`. +- So **creation is fully derivable**: + `{name, source: {source: "local", path: "./{dir}"}, policy: <the shared literal>, category: "Developer Tools"}`. + +### Why the creation boundary falls exactly where D36 puts it — now measured, not just argued + +- **Channel-one listing entry keys: `[description, name, source, version]`** — carries `description`, authored prose. +- **Channel-two per-plugin record** — *is* the plugin's authored presence (display name, short/long descriptions, + example prompts; see any `.codex-plugin/plugin.json`). +- **Channel-one per-plugin record** — carries `description`, authored prose. +- **Channel-two listing entry — the only target with no authored field in it.** + +That is the one-line justification for the whole create-path, and it is checkable rather than argued: a release can create +the channel-two listing entry because every field in it is derivable, and cannot create anything else because every other +target contains prose a person wrote. + ## Verified state (measured, not assumed) - **Exactly 8 plugins' channel-two records disagree with channel one.** Measured this session: @@ -73,9 +98,24 @@ Three version records per plugin, not two — channel one's listing carries a ve ## Documents phase 3 must correct (the release-procedure docs it falsifies) -- **`CONTRIBUTING.md:138`** — "When a change adds, removes, or moves a skill between plugins, update the marketplace - registry at `.claude-plugin/marketplace.json`". **Names one target where there are four.** Confirmed live. Also - `CONTRIBUTING.md:157` ("Update the marketplace registry at …") in the Adding-a-skill steps. +> **CORRECTED after review (junior-developer finding JD-006).** An earlier version of this section named +> `CONTRIBUTING.md:138` and `:157` as passages phase 3 must rewrite to list four targets. **That was wrong**, and acting +> on it would have made the guide wrong in a new way. Both lines are about moving a **skill** between plugins, which +> touches none of the four publishing targets. Phase 3 does not falsify them, so D7 ("a document is in scope when this +> work falsifies it") excludes them. **Leave lines 138 and 157 alone.** Re-verified live. + +- **`CONTRIBUTING.md` has no "Adding a plugin" section.** Verified: its headings are TL;DR, Before you start, Setting up + your environment, Which plugin does the change belong in?, Adding a skill, Adding an agent, Wiring the readability + standard into a skill, Editing an existing long-form doc, Writing voice, Documentation conventions, Reviewing your own + changes, Related Documentation. A contributor adding a plugin has **no instructions at all**. +- **No contributor-facing document mentions channel two's files.** Verified: grepping `CONTRIBUTING.md`, `README.md`, and + `docs/quickstart.md` for `agents/plugins` or `codex-plugin` returns nothing. (`README.md` documents Codex as an + *install* channel for users; nothing tells a *contributor* those files exist.) +- **So this is an addition, not a correction.** The spec's phrasing at `feature-specification.md:558` — "step 3 corrected + the contributor guide to name all four targets rather than one" — presumes a passage that names one target for the + plugin-adding flow. There is no such passage. Phase 3 **adds** a short "Adding a plugin" section naming the four + files. Phase 6's fairness argument rests on it: a contributor cannot be told they missed a target no document ever + named. - The release skill's own prose describes a two-target release throughout. ## Gaps — searched for, not found From 838af44b275d076c99160829d59bf1ef44ab99f0 Mon Sep 17 00:00:00 2001 From: River Lynn Bailey <river.bailey@testdouble.com> Date: Fri, 17 Jul 2026 08:46:08 -0600 Subject: [PATCH 21/22] docs(plans): record round 1 of phase 3 implementation planning Four specialists in parallel: junior-developer, devops-engineer, edge-case-explorer, test-engineer. 26 claims, all evidenced; one disputed (bundle identity) resolved by measurement. Spec-maturity gate not tripped. Two independent reviews converged from opposite directions on the same structural call: the writes must be script-borne, because the testability boundary and the one-bearer rule are the same line. --- .../implementation-iteration-history.md | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 docs/plans/han-publishing-cleanup/artifacts/implementation-iteration-history.md diff --git a/docs/plans/han-publishing-cleanup/artifacts/implementation-iteration-history.md b/docs/plans/han-publishing-cleanup/artifacts/implementation-iteration-history.md new file mode 100644 index 00000000..96cf1464 --- /dev/null +++ b/docs/plans/han-publishing-cleanup/artifacts/implementation-iteration-history.md @@ -0,0 +1,81 @@ +# Implementation iteration history — phase 3 + +Companion to [../feature-implementation-plan.md](../feature-implementation-plan.md) and +[implementation-decision-log.md](implementation-decision-log.md). + +Source spec: [../feature-specification.md](../feature-specification.md). Phase: +[../build-phase-outline.md](../build-phase-outline.md) Phase 3. + +Size: **Medium** (two-to-three subsystems: the release skill, a new check with two consumers, the docs it falsifies; no +auth/PII surface). Team cap 5, round cap 2. + +--- + +## R1 — parallel specialist review + +**Specialists engaged.** `han-core:junior-developer` (required; generalist stress-test and reframing), +`han-core:devops-engineer` (the release gate, rollout ordering, and recovery are the spine), +`han-core:edge-case-explorer` (the refusal list and the silent-failure commitments), +`han-core:test-engineer` (the check's testability and contract). All four launched in parallel with domain-scoped briefs +plus [.discovery-notes.md](.discovery-notes.md). + +**New input provided.** The measured current state (8 drifted plugins, four target paths, the bundle's shape, the plugin +set's clean derivability), the release skill's exact defect sites, and the spec's committed constraints. + +### Claim ledger + +| # | Claim | Category | State | Raised by | Maturity | +|---|-------|----------|-------|-----------|----------| +| C1 | The writes must be script-borne, not prose-borne, or the bundle exception lands in two places and D14 is violated at the write step | overlap | **Evidenced** (D14's own rationale names this outcome in advance) | devops | plan-level | +| C2 | `repair` must iterate the derived plugin set unconditionally; inheriting Step 4's `target == current` skip repairs nothing | assumption-refuted | **Evidenced** (`SKILL.md:238`; none of the 8 drifted plugins would be bumped by an ordinary release) | devops | plan-level | +| C3 | The gate belongs immediately after the writes (Step 4.5), not at Step 7 | ambiguity | **Evidenced**, independently by two specialists | devops, junior-dev | plan-level | +| C4 | Step 5's changelog augment path is **not idempotent** (it appends), so a stop after it duplicates on re-run — an independent argument for the early gate | edge-case | **Evidenced** (`SKILL.md:249-255`) | devops | plan-level | +| C5 | The gate must read disk state fresh after the writes; the skill's Project Context is a frontmatter block evaluated **once at invocation**, before the plan and the writes exist | edge-case | **Evidenced** (`SKILL.md:31-41`) | edge-case | plan-level | +| C6 | The untracked-created-file hazard has **zero members** — the channel-two listing is tracked and D36 confines creation to an entry inside it. The spec's rationale is a leftover from a draft where creation reached per-plugin records | assumption-refuted | **Evidenced** (verified: `git ls-files --error-unmatch` succeeds) | devops | spec-level (rationale only; behavior unchanged) | +| C7 | Hedging against C6's non-hazard invites `git clean -fd` into the recovery, which deletes a solo maintainer's unrelated untracked work with no undo — a real hazard created by guarding a fake one | edge-case | **Evidenced** | devops | plan-level | +| C8 | The repair manufactures version bumps: `{dir}/.codex-plugin/plugin.json` is inside `{source}/`, so phase 4's hand-corrections make 8 plugins classify as "changed" → 8 meaningless patch bumps. Recurs on every contributor record fix, which is what phase 6 exists to cause | edge-case | **Evidenced** (verified live against `v4.6.0`) | devops | plan-level | +| C9 | The only target a release can create carries **no version field**, so "created at the version it is publishing" is vacuous for it | assumption-refuted | **Evidenced** (measured: `[.plugins[] \| has("version")] \| unique` → `[false]`) | junior-dev, devops | spec-level (D31 vacuous for creation; binds the records) | +| C10 | D36's boundary is **measurable, not just argued**: channel two's listing entry is the only target with no authored field in it | — | **Evidenced** (measured key sets across all four targets) | junior-dev | plan-level | +| C11 | The check needs **no mode flag**: the release repairs before the check runs, so the repair set and the gap set are exact complements. The surfaces differ by *when*, not *what* | YAGNI-candidate | **Evidenced** (junior-dev walked every row of the spec's failure table; devops concurred independently) | junior-dev, devops | plan-level | +| C12 | The repair must **skip what it cannot safely write** and defer to the single gate; aborting at the unreadable file names one gap and breaks D12 | edge-case | **Evidenced** (D12 + D35 both already demand this shape) | junior-dev | plan-level | +| C13 | `jq -r` prints the literal string `null` for both an explicit null and an absent key, so two broken records string-compare **equal** — defeating D35's "two unreadable values never agree" | edge-case | **Evidenced** (verified directly) | edge-case | plan-level | +| C14 | The skill's existing `jq … 2>/dev/null` idiom, if copied to the listing read, turns a parse failure into "zero entries" — routing the whole channel into the create-path and regenerating the storefront | edge-case | **Evidenced** (`SKILL.md:37,39`; D35 names this outcome) | edge-case | plan-level | +| C15 | Plugin derivation needs a **depth bound** (`*/.claude-plugin/plugin.json`, not a recursive find): `han-plugin-builder` teaches authoring these files and its example avoids collision only by two incidental naming accidents | edge-case | **Evidenced** | edge-case | plan-level | +| C16 | An **empty derived plugin set** makes the check find zero gaps and exit green — invisible-by-construction wearing the gate's clothes | edge-case | **Evidenced** | devops | plan-level | +| C17 | Cross-target matching must key on plugin `name`, not the listing's `source` path (the skill already sets this precedent at `SKILL.md:236`) | edge-case | **Evidenced** | edge-case | plan-level | +| C18 | `CONTRIBUTING.md:138` and `:157` are about moving a **skill**, which touches none of the four targets — phase 3 does **not** falsify them. The real gap is that no "Adding a plugin" section exists and no contributor doc mentions channel two at all | assumption-refuted | **Evidenced** (independently re-verified by the skill author) | junior-dev | plan-level | +| C19 | Testing against the live tree is a trap: phase 4 fixes the 8, so any test asserting them flips; and a green-check test goes red on phase 3's own PR and stays red through phase 4, landing phase 6's red signal four phases early | edge-case | **Evidenced**, independently by two specialists | test-eng, devops | plan-level | +| C20 | The testability boundary is exactly D14's line — queries are script-testable, writes are not. Push the five-gap logic into the check; have the release consume its findings | — | **Evidenced** | test-eng | plan-level | +| C21 | The creation step should consume the check's own gap list rather than re-deriving it, making the bundle's third verb (*not created*) correct **by construction** | overlap | **Evidenced** | test-eng | plan-level | +| C22 | Prose cannot be trusted to gate — nothing stops an agent skipping the check and reaching the push. But the observed failure class is prose that was *wrong*, followed faithfully; an enforcement wrapper guards a class with zero members | ambiguity | **Evidenced** (D14's own rationale) | junior-dev, devops | plan-level | +| C23 | `allowed-tools` grants `Write`/`Edit`, so on a stop the agent can author the missing record itself and re-run to green — D36's boundary crossed by the actor the gate exists to stop. Nothing can be built to prevent it | edge-case | **Evidenced** (`SKILL.md:16-18`) | devops | plan-level | +| C24 | Step 8.1's staging list is a hand enumeration — the same defect class D37 found. Stage by class pathspec | overlap | **Evidenced** | devops | plan-level | +| C25 | The bundle's identity: **derive** from the listing's top-level `.name` vs **hardcode** the literal `han` | — | **Disputed** → resolved, see below | devops + edge-case (derive) vs junior-dev (literal) | plan-level | +| C26 | OQ-5 (approved plan vs repair disagreement) is already answered by the spec — Option A, stated as the ordinary path | — | **Evidenced** | junior-dev, devops | plan-level | + +### Spec-maturity gate + +**Not tripped.** T#-contradictions: **0** (no specialist contradicted T1 or T2). `spec-level` findings: **3** (C6, C9, and +C18's premise), raised by **2** distinct specialists. The gate requires ≥2 T#-contradictions by ≥2 specialists, or ≥5 +`spec-level` findings by ≥3 specialists. Neither threshold met, and all three are rationale/wording corrections that +leave every behavioral commitment intact. No PM facilitation pass was made. + +### Open Questions and resolutions + +| OQ | Question | Resolution source | Answer | +|----|----------|-------------------|--------| +| OQ-A | Does invoking a script from a skill prompt when `allowed-tools` has no matching pattern? | evidence (deferred to the cheap safe action) | Not worth a separate experiment. Both specialists recommend adding the pattern; it is one frontmatter line and costs nothing if unnecessary. Add `Bash(./scripts/publishing-targets.sh:*)`. | +| OQ-B | Where does the check script live? | evidence (convergence) | `scripts/publishing-targets.sh` at the repo root. Both specialists reached this independently: it is repo maintenance, not any plugin's behavior; shipping it inside `han-*/` would send dead weight to installers and make every fix bump a version the check itself polices; three callers live in three trees. Verified free consequences: the ShellCheck hook has no `files` filter so a root script is covered automatically, and the shebang hook requires `chmod +x` in the same commit. | +| OQ-C | Does phase 3 own authoring the check, or assume it exists? | evidence | Phase 3 owns it. D14 ("the check is executable") plus Phase 6 ("the rule itself is not new here") leave no other reading. The outline's phrasing is an omission, not an ambiguity. Phase 3 is roughly twice the size the outline's wording implies. | +| OQ-D | Bundle identity: derive or hardcode? (C25, Disputed) | evidence | **Derive** from `.claude-plugin/marketplace.json`'s top-level `.name`. The junior-developer's objection — that deriving from a file the check exists to distrust is a knot — dissolves on measurement: no release step writes the top-level `.name` (only `.plugins[].version`), and the check needs that listing readable regardless, so an unreadable listing exits 2 either way. Deriving costs nothing and reuses the definition already at `SKILL.md:44-46`. Verified: marketplace `.name` = `han` = the bundle dir's own name. | +| OQ-5 (inherited) | What should a release do when the approved plan and a target's repair disagree? | evidence | **Discharged by the spec — Option A.** `feature-specification.md:497-500` states it as the ordinary path ("proceeds, whether or not it bumped that plugin this release"); the Deferred-YAGNI item at `:743-756` establishes that creation itself gets no sign-off, so a repair (a strictly smaller act) cannot need one. Under a script-borne argument-free `repair` it is structural rather than instructed. Strike from phase 3's preconditions; do not spend a maintainer round-trip. | + +### Next-step recommendation + +**Go to synthesis.** The gate did not trip. Every Open Question resolved by evidence or convergence; none required user +input. No specialist named an unengaged handoff (the test-engineer's optional `test-engineer` handoff was already on the +team). The two independent reviews converged on the same structural call (C1/C20 — script-borne writes, one bearer) from +opposite directions, which is the strongest signal available that the call is right. + +**Decisions produced:** — _(backfilled at synthesis)_ +**Changed in plan:** — _(backfilled at synthesis)_ From 2c09e8f6f2d725b8810d481ee2d0e4c53f45e450 Mon Sep 17 00:00:00 2001 From: River Lynn Bailey <river.bailey@testdouble.com> Date: Fri, 17 Jul 2026 09:09:29 -0600 Subject: [PATCH 22/22] docs(plans): add phase 3 implementation plan Project-manager synthesis of the medium-team round. 22 decisions (19 full, 3 trivial), all settled by evidence; 9 YAGNI deferrals with reopen triggers; no blocking open items. Load-bearing call, reached independently by two reviewers from opposite directions: the release's writes become script-borne via a repo-root scripts/publishing-targets.sh with argument-free check and repair verbs, so the rule has one bearer shared with phase 6's CI check. Gate lands at a new Step 4.5 (after the writes, before anything irreversible); recovery is a scoped git restore, never git clean; change detection excludes the codex manifest so the repair does not manufacture patch bumps; tests use synthetic fixtures. CONTRIBUTING.md gains an Adding-a-plugin section rather than editing lines phase 3 does not falsify. --- .../artifacts/implementation-decision-log.md | 324 +++++++++++++++ .../implementation-iteration-history.md | 11 +- .../feature-implementation-plan.md | 390 ++++++++++++++++++ 3 files changed, 723 insertions(+), 2 deletions(-) create mode 100644 docs/plans/han-publishing-cleanup/artifacts/implementation-decision-log.md create mode 100644 docs/plans/han-publishing-cleanup/feature-implementation-plan.md diff --git a/docs/plans/han-publishing-cleanup/artifacts/implementation-decision-log.md b/docs/plans/han-publishing-cleanup/artifacts/implementation-decision-log.md new file mode 100644 index 00000000..350a51d6 --- /dev/null +++ b/docs/plans/han-publishing-cleanup/artifacts/implementation-decision-log.md @@ -0,0 +1,324 @@ +# Implementation Decision Log: Han Publishing Cleanup — Phase 3 + +<!-- +This file records every implementation decision committed while planning phase 3 +("Make a release bring every publishing target up to date"). Behavioral and +implementation statements live in [../feature-implementation-plan.md](../feature-implementation-plan.md) — +this file captures the question, rationale, evidence, and rejected alternatives for +each decision. Round-by-round history lives in +[implementation-iteration-history.md](implementation-iteration-history.md). + +The D-N counter is shared across the trivial and full sections. Cross-referencing +invariants (Driven by rounds / Dependent decisions / Referenced in plan) are kept in +sync with the plan and the iteration history. +--> + +## Trivial decisions + +- D-14: Match cross-target on plugin `name`, never the listing's `source` path — every cross-target join (record to listing entry, gap to plugin) keys on the plugin `name`, following the precedent the release skill already sets at `SKILL.md:236`. — Referenced in plan: Implementation Approach (Runtime Behavior). +- D-19: Add the script to `allowed-tools` — add `Bash(./scripts/publishing-targets.sh:*)` to the `han-release` frontmatter so the skill can invoke the check without a permission prompt; one line, costs nothing if it turns out unnecessary ([OQ-A](implementation-iteration-history.md#r1--parallel-specialist-review)). — Referenced in plan: Implementation Approach (Architecture and Integration Points). +- D-21: Report both created and changed targets — Steps 7 and 10 report what the release created and what it changed per plugin and target, alongside the version plan, implementing spec D39; a created channel-two listing entry names no version (see D-7). — Referenced in plan: Implementation Approach (Runtime Behavior), Definition of Done. + +## Full decisions + +### D-1: The publishing writes are script-borne + +- **Question:** Should the four-target repair, creation, and gap logic live in the release skill's prose, or in an executable script? +- **Decision:** Create one script, `scripts/publishing-targets.sh`, at the **repository root**, exposing two argument-free verbs: `check` (report gaps) and `repair` (bring the writable targets up to date). The release skill invokes it rather than restating its rule. +- **Rationale:** Spec D14 ("the release runs the check rather than restating it") already commits to one rule with one bearer shared with phase 6. Two independent specialist reviews converged on the same call from opposite directions (C1 from the DevOps side, C20 from the testability side): the testable boundary is exactly D14's line — queries and writes are script-testable, prose is not. Root placement (not inside a `han-*/` plugin) is forced by three facts: it is repo maintenance, not any plugin's shipped behavior; a copy inside `han-*/` ships dead weight to installers and makes every fix bump a version the check itself polices; and three callers (the release skill, phase 6's CI step, the optional prek hook) live in three trees. Root placement has verified-free consequences: the ShellCheck hook has no `files` filter so a root script is covered automatically, and the shebang hook is satisfied by `chmod +x` in the same commit. +- **Evidence:** Spec D14 (`feature-specification.md:274-275`); `check-annotations.sh` precedent (multi-input, names every finding at once, one bearer with two callers); iteration-history C1, C20, OQ-B; `.pre-commit-config.yaml:24-27,49-50` (ShellCheck has no `files` filter; shebang-executable hook). +- **Rejected alternatives:** + - Rule restated in SKILL.md prose — rejected because it puts the bundle exception in two places and violates D14 at the write step (C1); and prose cannot be tested (C20). + - Script inside a `han-*/` plugin directory — rejected because it ships to installers as dead weight and every fix bumps a version the check polices (OQ-B). +- **Specialist owner:** devops-engineer. +- **Revisit criterion:** A fourth caller appears whose needs the two argument-free verbs cannot serve, or the root script is found to conflict with a lint/hook path filter. +- **Dissent (if any):** None. +- **Driven by rounds:** R1. +- **Dependent decisions:** D-2, D-3, D-5, D-6, D-10, D-11, D-12, D-13, D-16, D-17, D-19. +- **Referenced in plan:** Outcome, Implementation Approach (Architecture and Integration Points), Decomposition and Sequencing. + +### D-2: Repair iterates the derived plugin set unconditionally + +- **Question:** Should `repair` inherit Step 4's `target == current` skip, or write every writable target for every derived plugin regardless? +- **Decision:** `repair` iterates the derived plugin set **unconditionally**, writing the publishing version onto every writable version record and creating any missing channel-two listing entry. It does not carry Step 4's "skip any plugin whose `target == current`" guard. +- **Rationale:** Step 4's skip is correct for the channel-one record write (an unchanged plugin's record is already right), but that skip **is** the drift mechanism: none of the eight drifted channel-two records would be touched by an ordinary release, because those plugins are not being bumped. Inheriting the skip repairs nothing. +- **Evidence:** `SKILL.md:237` (the skip); discovery notes (8 drifted plugins, none of which an ordinary release bumps); iteration-history C2. +- **Rejected alternatives:** + - Reuse Step 4's skip for the repair — rejected because it leaves every drifted record drifted no matter how many releases run (C2), which is the exact defect phase 3 exists to end. +- **Specialist owner:** devops-engineer. +- **Revisit criterion:** The repair is measured writing a record that was already correct in a way that produces a spurious change (it should be idempotent; a spurious diff would signal a bug). +- **Dissent (if any):** None. +- **Driven by rounds:** R1. +- **Dependent decisions:** D-9. +- **Referenced in plan:** Implementation Approach (Runtime Behavior), Decomposition and Sequencing. + +### D-3: The gate is a new Step 4.5 + +- **Question:** Where in the release flow does the gate (the `check` call that can stop the release) run? +- **Decision:** The gate is a **new Step 4.5**, immediately after the writes (Step 4) and before the changelog augment (Step 5). +- **Rationale:** Three arguments converge on this placement. It keeps spec D34's discard set to exactly the four target paths (nothing downstream has been written yet). Step 5's changelog augment path is **not idempotent** — it appends — so a stop after it duplicates content on re-run (C4). And a stop at 4.5 costs zero `general-purpose` agent dispatches (the changelog narrative agent runs in Step 5). Spec D24 requires the gate after all targets are written and before anything irreversible; 4.5 is the earliest point that satisfies it. +- **Evidence:** Spec D24 (`feature-specification.md:250-261`), D34 (`:511-513`); `SKILL.md:249-255` (Step 5 augment appends, not idempotent); iteration-history C3 (raised independently by two specialists), C4. +- **Rejected alternatives:** + - Gate at Step 7 (show the prepared release) — rejected because Step 5's augment would already have run and duplicates on re-run (C4), and the discard set would widen beyond the four targets (C3). +- **Specialist owner:** devops-engineer. +- **Revisit criterion:** A future step is inserted between Step 4 and Step 5 that itself writes a non-idempotent artifact, forcing the gate earlier. +- **Dissent (if any):** None. +- **Driven by rounds:** R1. +- **Dependent decisions:** D-4, D-8. +- **Referenced in plan:** Implementation Approach (Runtime Behavior), Decomposition and Sequencing. + +### D-4: The gate reads disk state fresh + +- **Question:** Does the gate evaluate the plugin/target state captured in the skill's Project Context frontmatter, or read disk fresh at invocation time? +- **Decision:** The gate (and the `check`/`repair` script generally) **reads disk fresh**. It does not consume the skill's Project Context values. +- **Rationale:** The skill's Project Context is a frontmatter block of `!`-prefixed commands evaluated **once at invocation**, before the plan is confirmed and before Step 4's writes exist. Wiring the gate to those captured values builds a gate that checks the pre-repair state and always passes. The bug is invisible in review because the wiring looks correct. Because the script runs as a subprocess at Step 4.5, reading disk fresh is structural rather than a discipline the prose must remember. +- **Evidence:** `SKILL.md:31-41` (Project Context is a one-time frontmatter block); iteration-history C5. +- **Rejected alternatives:** + - Pass the Project Context plugin list / versions into the gate — rejected because those values predate the writes, so the gate would check pre-repair state and pass regardless (C5). +- **Specialist owner:** edge-case-explorer. +- **Revisit criterion:** None foreseeable; a change that makes the check read cached state would reintroduce the defect. +- **Dissent (if any):** None. +- **Driven by rounds:** R1. +- **Dependent decisions:** — +- **Referenced in plan:** Implementation Approach (Runtime Behavior). + +### D-5: Repair skips what it cannot safely write + +- **Question:** When `repair` reaches a target it cannot safely write (for example an unreadable record), does it abort, or skip and continue? +- **Decision:** `repair` **skips what it cannot safely write** and continues, leaving the unsafe target for the single gate at Step 4.5 to name as a gap. +- **Rationale:** Aborting at the first unreadable file names one gap and stops, which breaks spec D12 ("name every gap at once, not the first"). The repair's job is to close what it can; the gate's job is to name what remains. Splitting those responsibilities is what lets D12 and D35 both hold: the repair writes the writable, the gate reports the complete residue in one run. +- **Evidence:** Spec D12 (`feature-specification.md:254-255`), D35 (`:606-608`); iteration-history C12. +- **Rejected alternatives:** + - Abort the repair at the first target it cannot write — rejected because it surfaces one gap instead of the full set and breaks D12 (C12). +- **Specialist owner:** junior-developer. +- **Revisit criterion:** A class of unsafe write is found where continuing corrupts a later target rather than merely leaving it unrepaired. +- **Dissent (if any):** None. +- **Driven by rounds:** R1. +- **Dependent decisions:** D-6. +- **Referenced in plan:** Implementation Approach (Runtime Behavior). + +### D-6: The check takes no mode flag + +- **Question:** Does the check need a mode flag (release vs. pull-request) so the two callers get different behavior? +- **Decision:** **No mode flag.** The check's contract is: no arguments, cwd is the repository root, exit `0` clean / exit `1` gaps found (all named at once, per D12) / exit `2` cannot answer (an unreadable target per D35, or an empty derived plugin set per C16). The two surfaces differ by **when** the check is called, not by **what** it asks. +- **Rationale:** On a release the repair runs first, so the repair set and the gap set are exact complements — by the time the check runs, everything the check would otherwise have caught the release doing has already been repaired. On a pull request nothing repairs, so the same question surfaces the drift directly. One question, two moments, one answer shape. Exit `2` for an empty plugin set closes the "invisible-by-construction" hole where a derivation that finds no plugins finds no gaps and exits green (C16). +- **Evidence:** Spec D12, D14, D35, D38 (`feature-specification.md:604-608,634-636`); iteration-history C11 (junior-dev walked every row of the spec's failure table; devops concurred independently), C16. +- **Rejected alternatives:** + - A `--release` / `--pr` mode flag (or `--json`, `--fix`, `--dry-run`, severity taxonomy) — rejected because both callers need the same answer and an exit code beats a taxonomy; deferred under YAGNI with a reopen trigger (a third caller needing a different answer). +- **Specialist owner:** test-engineer. +- **Revisit criterion:** A third caller needs a different answer from the same check. +- **Dissent (if any):** None. +- **Driven by rounds:** R1. +- **Dependent decisions:** D-10, D-12, D-13, D-16, D-17. +- **Referenced in plan:** Implementation Approach (Architecture and Integration Points, Runtime Behavior), Testing Strategy. + +### D-7: The created channel-two entry carries no version + +- **Question:** What exact fields does a release write when it creates a channel-two storefront listing entry, and does D31's "at the version it is publishing" apply? +- **Decision:** The created entry carries no `version` field. Its exact field set is: + + ```json + { + "name": "{name}", + "source": { "source": "local", "path": "./{dir}" }, + "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" }, + "category": "Developer Tools" + } + ``` + + D31's "created at the version it is publishing" binds the version **records** and is vacuous for this created entry; D39's report line for a creation names no version. +- **Rationale:** Measured this session: channel-two listing entry keys are exactly `[category, name, policy, source]` — `[.plugins[] | has("version")] | unique` returns `[false]`. `policy` and `category` are 100% uniform across all existing entries. So creation is fully derivable and carries no version. This is also the **measurable** form of D36's boundary (C10): channel two's listing entry is the only target of the four with no authored field in it — the one-line, checkable justification for the whole create-path, which the plan states outright rather than arguing. +- **Evidence:** Discovery notes JD-001 (measured key sets and uniformity); spec D31 (`feature-specification.md:231-235`), D36, D39; iteration-history C9, C10. +- **Rejected alternatives:** + - Write a `version` onto the created entry (a literal read of D31) — rejected because the entry has no version field and never has; D31 is vacuous here (C9). + - Derive `category`/`policy` per-plugin — rejected because all existing entries are uniform; the constants are correct and the per-plugin derivation guards a case with no members (deferred under YAGNI). +- **Specialist owner:** junior-developer. +- **Revisit criterion:** A plugin needs a non-"Developer Tools" category or a non-default policy, or channel two's schema adds a version to listing entries. +- **Dissent (if any):** None. +- **Driven by rounds:** R1. +- **Dependent decisions:** D-10. +- **Referenced in plan:** Implementation Approach (Data Model and Persistence, Runtime Behavior). + +### D-8: Recovery is a scoped git restore, never git clean + +- **Question:** How does the operator recover from a gate stop at Step 4.5 — and does recovery need to delete untracked files? +- **Decision:** Recovery discards the release's local work with a **scoped `git restore` of the four tracked target paths**, never `git clean`. The untracked-file hazard the spec hedged against has zero members, so no untracked-deletion step is specified. +- **Rationale:** The channel-two listing is a tracked file and creation only modifies an entry inside it, so `git ls-files --error-unmatch` succeeds for every target creation touches — the "created file is untracked" hazard has no members (C6). Hedging against a non-hazard invites `git clean -fd` into the recovery, which deletes a solo maintainer's unrelated untracked work with no undo — a real hazard manufactured to guard a fake one (C7). Spec D34's behavior (a gate stop costs a separate commit) stands on its **other** reason: committing the gap fix together with half-applied version writes makes those writes look like deliberate bumps, so the next run takes the ahead path and publishes versions nobody approved. This corrects a spec **rationale** (C6 is spec-level) without changing D34's behavior. If creation is ever widened to write a **new** file, the hazard regains members and the recovery must be revisited. +- **Evidence:** Spec D34 (`feature-specification.md:508-534`); verified `git ls-files --error-unmatch` succeeds for the channel-two listing; iteration-history C6, C7. +- **Rejected alternatives:** + - `git clean -fd` (or any untracked-deletion) in the recovery — rejected because it deletes unrelated untracked work to guard a hazard with zero members (C6, C7). +- **Specialist owner:** devops-engineer. +- **Revisit criterion:** Creation is widened to write a new file rather than an entry inside a tracked file; then untracked artifacts exist and the recovery needs an untracked-aware step. +- **Dissent (if any):** None. +- **Driven by rounds:** R1. +- **Dependent decisions:** — +- **Referenced in plan:** Implementation Approach (Runtime Behavior), Operational Readiness, RAID Log. + +### D-9: Exclude the channel-two record from change detection + +- **Question:** Does the release's change-detection (Step 3a) count `{source}/.codex-plugin/plugin.json` edits as a plugin "change"? +- **Decision:** **Exclude** `{source}/.codex-plugin/plugin.json` from Step 3a's change detection. Do **not** add the symmetric exclusion for `{source}/.claude-plugin/plugin.json`. +- **Rationale:** `{dir}/.codex-plugin/plugin.json` lives inside `{source}/`, so phase 4's hand-corrections make all 8 plugins classify as "changed", and the next release proposes 8 meaningless patch bumps — recurring on every contributor record fix, which is exactly the kind of fix phase 6 exists to cause. Verified live against `v4.6.0`. The symmetric exclusion for `.claude-plugin/plugin.json` is beyond phase 3's remit: it would also stop a description-only edit from bumping, a behavior change with no measured instance. +- **Evidence:** `SKILL.md:168,171-172` (Step 3a change detection); verified live against `v4.6.0`; iteration-history C8. +- **Rejected alternatives:** + - Leave change detection unchanged — rejected because phase 4 and every later record fix then manufacture spurious patch bumps (C8). + - Add the symmetric `.claude-plugin/plugin.json` exclusion too — rejected because there is no measured instance and it changes behavior beyond phase 3's remit (deferred under YAGNI). +- **Specialist owner:** devops-engineer. +- **Revisit criterion:** A description-only edit to `.claude-plugin/plugin.json` is observed causing an unwanted bump. +- **Dissent (if any):** None. +- **Driven by rounds:** R1. +- **Dependent decisions:** — +- **Referenced in plan:** Implementation Approach (Runtime Behavior), Decomposition and Sequencing. + +### D-10: Creation consumes the check's gap list + +- **Question:** Does the creation step re-derive which channel-two listing entries are missing, or consume the check's own gap output? +- **Decision:** Creation **consumes the check's own gap list** rather than re-deriving it. +- **Rationale:** Consuming the check's output makes the bundle's third verb (*not created*) correct **by construction** rather than by a second rule that must independently reproduce the bundle exception. One derivation of "what is missing", one place the exception lives. +- **Evidence:** Spec D6 (`feature-specification.md:280-286`), D14; iteration-history C21. +- **Rejected alternatives:** + - Re-derive the missing set inside the creation step — rejected because it duplicates the bundle exception into a second rule that can drift from the check (C21). +- **Specialist owner:** test-engineer. +- **Revisit criterion:** The check's output format changes such that creation can no longer consume it directly. +- **Dissent (if any):** None. +- **Driven by rounds:** R1. +- **Dependent decisions:** — +- **Referenced in plan:** Implementation Approach (Runtime Behavior). + +### D-11: Bundle identity is derived from the listing name + +- **Question:** Does the check identify the bundle (the permanent channel-two exception) by deriving it from a file, or by hardcoding the literal `han`? +- **Decision:** **Derive** the bundle identity from `.claude-plugin/marketplace.json`'s top-level `.name`. +- **Rationale:** The junior-developer's objection — deriving from a file the check exists to distrust is a knot — dissolves on measurement: no release step writes the top-level `.name` (only `.plugins[].version`), and the check needs that listing readable regardless (an unreadable listing exits 2 either way). Deriving costs nothing and reuses the definition already at `SKILL.md:44-46`. Verified: marketplace `.name` = `han` = the bundle directory's own name. +- **Evidence:** `SKILL.md:44-46` (existing parent definition); measured marketplace `.name` = `han`; no release step writes top-level `.name`; iteration-history C25 (Disputed → resolved), OQ-D. +- **Rejected alternatives:** + - Hardcode the literal `han` — rejected because it duplicates a definition that already exists at `SKILL.md:44-46` and would need hand-editing if the bundle is ever renamed (OQ-D). +- **Specialist owner:** devops-engineer. +- **Revisit criterion:** A release step begins writing the top-level `.name`, or a second permanent exception appears that the single-name derivation cannot express. +- **Dissent (if any):** junior-developer argued for hardcoding the literal `han` (deriving from a distrusted file is a knot); resolved by measurement — no release step writes `.name` and the listing must be readable anyway. Recorded under disagree-and-commit. +- **Driven by rounds:** R1. +- **Dependent decisions:** — +- **Referenced in plan:** Implementation Approach (Architecture and Integration Points). + +### D-12: Guard the jq parse and null traps + +- **Question:** How does the check read JSON without two silent failure modes: `jq -r` printing `null`, and the skill's `2>/dev/null` idiom masking a parse failure as empty? +- **Decision:** The check **guards both traps.** It never lets `jq -r`'s literal `null` output stand in for a real value, and it distinguishes a parse failure from a successfully-parsed empty array rather than copying the skill's `jq … 2>/dev/null` idiom to the listing read. +- **Rationale:** `jq -r` prints the literal string `null` for both an explicit null and an absent key, so two broken records string-compare **equal** — defeating D35's "two unreadable values never agree" (C13). Separately, the skill's existing `jq … 2>/dev/null` idiom, copied to a listing read, turns a parse failure into "zero entries", routing the whole channel into the create-path and regenerating the storefront — the loudest possible version of the silent defect this work exists to end (C14). Parse failure must exit 2; a parsed empty array is a different, answerable state. +- **Evidence:** `SKILL.md:37,39` (the `2>/dev/null` idiom); spec D35 (`feature-specification.md:606-608`); verified `jq -r` prints `null` for both cases; iteration-history C13, C14. +- **Rejected alternatives:** + - Copy the skill's `jq … 2>/dev/null` idiom to the check's reads — rejected because it converts a parse failure into a false "empty" and regenerates the storefront (C14). + - Compare `jq -r` outputs directly — rejected because two `null` strings compare equal and defeat D35 (C13). +- **Specialist owner:** edge-case-explorer. +- **Revisit criterion:** jq's null-output behavior changes, or a read path is added that bypasses the guard. +- **Dissent (if any):** None. +- **Driven by rounds:** R1. +- **Dependent decisions:** — +- **Referenced in plan:** Implementation Approach (Runtime Behavior), Testing Strategy. + +### D-13: Derive the plugin set with a depth-bounded jq glob + +- **Question:** How does the check derive the plugin set, and how does the release skill's Project Context enumeration (`SKILL.md:37-39`) change? +- **Decision:** Derive the plugin set by matching `*/.claude-plugin/plugin.json` (a **depth-bounded glob**, not a recursive `find`), reading identity with `jq`. Replace the Project Context enumeration at `SKILL.md:37-39` — which reads `jq -r '.plugins[] | …' .claude-plugin/marketplace.json`, the defect verbatim — with this repo-derived, depth-bounded enumeration. +- **Rationale:** The current enumeration takes its plugin list from a target it also writes (spec D5's whole subject). Deriving from the repository fixes it. The depth bound matters: `han-plugin-builder` teaches authoring `plugin.json` files, and its example avoids collision only by two incidental naming accidents — a recursive find would pick up authored examples. Measured: exactly 11 directories match `*/.claude-plugin/plugin.json` and all 11 are plugins, no false positives. `jq` is already granted in `allowed-tools`; `ls`/`find` are not, so a `jq`-driven glob keeps the permission surface unchanged. +- **Evidence:** Spec D5 (`feature-specification.md:196-197`); `SKILL.md:37-39` (the defect); discovery notes (11 dirs match, no false positives); `SKILL.md:17` (allowed-tools grants `jq`, not `ls`/`find`); iteration-history C15. +- **Rejected alternatives:** + - Recursive `find … -name plugin.json` — rejected because it picks up `han-plugin-builder`'s authored example files (C15). + - Keep deriving from `marketplace.json.plugins[]` — rejected because that is the "list from a target it also writes" defect spec D5 exists to remove. +- **Specialist owner:** edge-case-explorer. +- **Revisit criterion:** A real plugin is nested deeper than one directory below the root, or an authored `plugin.json` example is placed exactly one level down. +- **Dissent (if any):** None. +- **Driven by rounds:** R1. +- **Dependent decisions:** — +- **Referenced in plan:** Implementation Approach (Architecture and Integration Points, Runtime Behavior). + +### D-15: Add an "Adding a plugin" section to CONTRIBUTING + +- **Question:** Which contributor-doc passages does phase 3 falsify and must correct? +- **Decision:** **Add** a short "Adding a plugin" section to `CONTRIBUTING.md` naming the four publishing targets. **Leave `CONTRIBUTING.md:138` and `:157` alone.** This corrects an error in an earlier draft of the discovery notes. +- **Rationale:** Lines 138 and 157 are about moving a **skill** between plugins, which touches none of the four targets, so spec D7 ("a document is in scope when this work falsifies it") excludes them. No contributor doc mentions channel two at all today — verified by grepping `CONTRIBUTING.md`, `README.md`, `docs/quickstart.md` for `agents/plugins` or `codex-plugin`. So this is an **addition**, not a correction: a contributor adding a plugin currently has no instructions at all. Phase 6's fairness argument rests on the addition — a contributor cannot be told they missed a target no document ever named. +- **Evidence:** Discovery notes JD-006 correction (re-verified live); spec D7 (`feature-specification.md:355-359`), D21; `feature-specification.md:558`; iteration-history C18. +- **Rejected alternatives:** + - Rewrite `CONTRIBUTING.md:138`/`:157` to list four targets (the earlier discovery-notes instruction) — rejected because those lines are about moving a skill and phase 3 does not falsify them; acting on it would make the guide wrong in a new way (C18). +- **Specialist owner:** junior-developer. +- **Revisit criterion:** A contributor-doc passage is found that does narrate the plugin-adding publishing flow and is falsified by this work. +- **Dissent (if any):** None. +- **Driven by rounds:** R1. +- **Dependent decisions:** — +- **Referenced in plan:** Implementation Approach (Architecture and Integration Points), Decomposition and Sequencing. + +### D-16: Tests use synthetic fixtures; phase 3 ships script and bats only + +- **Question:** Do the check's tests run against the live repository tree, and does phase 3 ship the CI workflow step and prek hook? +- **Decision:** Tests use **synthetic `mktemp -d` fixtures**, never the live tree. Phase 3 ships the script **and** its bats tests (they run via `npm test` with no `ci.yml` change). Phase 6 ships the CI workflow step and the prek hook. +- **Rationale:** A test asserting the 8 drifted plugins flips to green when phase 4 lands; a test asserting check-exits-0 against the real repo goes red on phase 3's own PR and stays red through phase 4 — landing phase 6's red signal four phases early and destroying spec D1's sequencing rationale (a signal red from birth is one people scroll past). Synthetic fixtures are stable across phases. Phase 3's bats run under the existing `npm test` (Bats, `--recursive test/`) with no CI change, so shipping the tests now does not turn the check on at the PR surface — that is phase 6's job. Precedent: phase 2's `check-annotations.bats` builds files under `mktemp -d`. +- **Evidence:** Spec D1 (`feature-specification.md:319-324`); discovery notes (Bats runner, `test/*.bats` precedent, phase 3 does not touch `ci.yml`); `check-annotations.bats:11-13` (`mktemp -d` fixtures); iteration-history C19. +- **Rejected alternatives:** + - Test against the live repository tree — rejected because assertions on the 8 drifted plugins flip when phase 4 lands, and a green-check assertion goes red on phase 3's own PR through phase 4 (C19). + - Ship the CI step / prek hook in phase 3 — rejected because it makes the check visible on the PR surface four phases before phase 6, which owns that surface, and lands a red signal early (C19). +- **Specialist owner:** test-engineer. +- **Revisit criterion:** A behavior can only be exercised against real repo state; then a read-only, phase-stable assertion must be designed rather than reusing the live tree. +- **Dissent (if any):** None. +- **Driven by rounds:** R1. +- **Dependent decisions:** — +- **Referenced in plan:** Decomposition and Sequencing, Testing Strategy, Definition of Done. + +### D-17: State the enforcement limit and print findings verbatim + +- **Question:** Can the release be built to prevent an agent skipping the check or authoring a missing record itself, and if not, what does phase 3 commit to? +- **Decision:** **State the enforcement limit out loud** in the plan, and adopt two cheap mitigations: the skill prints the check's findings **verbatim** (not paraphrased), and Step 7's always-printed summary **includes the check's output**. No mechanism is built to prevent an agent skipping the check or authoring the missing record. +- **Rationale:** Prose cannot be trusted to gate — nothing stops an agent skipping the check, and `allowed-tools` grants `Write`/`Edit`, so on a stop the agent could author the missing record itself and re-run to green (C22, C23). Nothing can be built to prevent that. But the **observed** failure class is prose that was **wrong**, followed faithfully, for eleven releases — an executable check fixes that class completely, and an enforcement wrapper would guard a class with zero observed members. The release already rests on this same trust at Step 1.2's hard stop. Printing verbatim satisfies D14 plus the spec's "the message a contributor sees is the message a maintainer sees"; including the check's output in the always-printed Step 7 summary means an agent that skipped the check has nothing to paste. A forward note for phase 6: inherit `ci.yml`'s existing `push: main` trigger rather than narrowing to `pull_request`, for free post-hoc detection of a skipped release gate. +- **Evidence:** Spec D14 (`feature-specification.md:274-275`); `SKILL.md:16-18` (allowed-tools grants Write/Edit), `SKILL.md:72-74` (Step 1.2 hard stop rests on the same trust); `.github/workflows/ci.yml:8-11` (existing `push: main` trigger); iteration-history C22, C23. +- **Rejected alternatives:** + - Build an enforcement wrapper that gates commit/push behind the check's exit code — rejected because it guards a class with zero observed members and cannot in principle stop an agent that has Write/Edit (C22, C23); deferred under YAGNI. +- **Specialist owner:** devops-engineer. +- **Revisit criterion:** A release is cut where the gate was skipped, or the check's output is absent from the Step 7 summary. +- **Dissent (if any):** None. +- **Driven by rounds:** R1. +- **Dependent decisions:** — +- **Referenced in plan:** Implementation Approach (Runtime Behavior), RAID Log, Operational Readiness. + +### D-18: Stage by class pathspec + +- **Question:** How does Step 8.1 stage the release's writes — by a hand enumeration of paths, or by a class pathspec? +- **Decision:** **Stage by class pathspec** at Step 8.1, not by a hand enumeration of the target paths. +- **Rationale:** The current Step 8.1 staging list is a hand enumeration that commits two of the four targets — the same defect class spec D37 found (the release wrote four targets and committed two). A class pathspec captures every target the release writes, satisfying D37's "every target the release writes travels into the commit it tags". It is safe because Step 1.2 guarantees a clean tree at the start of the release, so the pathspec stages only the release's own writes. +- **Evidence:** Spec D37 (`feature-specification.md:263-267`); `SKILL.md:326-328` (the hand enumeration), `SKILL.md:72-74` (Step 1.2 clean-tree guarantee); iteration-history C24. +- **Rejected alternatives:** + - Extend the hand enumeration to list all four targets — rejected because a hand list is the exact defect class D37 found; the next added target reintroduces the omission (C24). +- **Specialist owner:** devops-engineer. +- **Revisit criterion:** The clean-tree guarantee at Step 1.2 is removed, so a class pathspec would stage unrelated pre-existing changes. +- **Dissent (if any):** None. +- **Driven by rounds:** R1. +- **Dependent decisions:** — +- **Referenced in plan:** Implementation Approach (Runtime Behavior), Decomposition and Sequencing. + +### D-20: OQ-5 is discharged by the spec as Option A + +- **Question:** What should a release do when the operator's approved version plan and a target's repair disagree (OQ-5)? +- **Decision:** **Option A — the repair is silent within the approved plan.** OQ-5 is discharged by the spec, not by a maintainer round-trip. Strike it from phase 3's preconditions. +- **Rationale:** `feature-specification.md:497-500` states the repair-and-proceed path as the ordinary path ("proceeds, whether or not it bumped that plugin this release"). The Deferred-YAGNI item at `:743-756` establishes that creation itself gets no sign-off, so a repair — a strictly smaller act — cannot need one. Under a script-borne, argument-free `repair` the behavior is structural rather than instructed: a bump decides a new version, a repair publishes one already decided, and a drifted version is derivable. +- **Evidence:** `feature-specification.md:497-500`, `:743-756`; iteration-history C26, OQ-5. +- **Rejected alternatives:** + - Option B — the plan names every target the release will touch before approval — rejected because it asks the operator to approve arithmetic (correcting a derivable drifted version is not a new decision), and D39's report already provides the notice; deferred under YAGNI. +- **Specialist owner:** junior-developer. +- **Revisit criterion:** A maintainer is surprised by a version the release wrote and reports the silent repair as unwanted. +- **Dissent (if any):** None. +- **Driven by rounds:** R1. +- **Dependent decisions:** D-21. +- **Referenced in plan:** Decomposition and Sequencing, Open Items. + +### D-22: Leave the frontmatter description's stale list alone + +- **Question:** The frontmatter `description` at `SKILL.md:3-14` carries the same stale plugin enumeration as the Vocabulary bullet. The Vocabulary bullet is being rewritten — should the description be corrected too, since the editor is already in the file? +- **Decision:** **Leave the frontmatter description alone.** Rewrite **only** the Vocabulary bullet at `SKILL.md:48` (and the Project Context enumeration per D-13). State this boundary explicitly in the plan. +- **Rationale:** The Vocabulary bullet's "children — every other entry in marketplace.json.plugins[]" is **falsified** by spec D5 (the release no longer derives children from the listing), so spec D33 (an already-false statement inside a rewritten passage is corrected) applies and D5 forces the rewrite. The frontmatter description is **not** being rewritten and nothing in it is falsified by this work, so spec D7 leaves it alone — even though it carries the same stale list two paragraphs up. "I'm already in the file and that list is wrong two paragraphs up" is exactly the symmetry reasoning the spec rejects (D7, and the Out-of-scope boundary at `feature-specification.md:701-708`), and an implementer will hit this within ten minutes, so the plan says it out loud. +- **Evidence:** `SKILL.md:3-14` (description), `SKILL.md:48` (Vocabulary bullet); spec D5, D7 (`feature-specification.md:355-359`), D33 (`:372-382`); Out-of-scope boundary (`:701-708`); iteration-history edit-site notes. +- **Rejected alternatives:** + - Correct the frontmatter description's stale list while editing the Vocabulary — rejected because the description is not falsified by this work; correcting it because the editor is open is the symmetry reasoning spec D7 and D33 explicitly refuse. +- **Specialist owner:** junior-developer. +- **Revisit criterion:** A later step falsifies the frontmatter description directly (for example a plugin rename this work performs), bringing it into scope under D7. +- **Dissent (if any):** None. +- **Driven by rounds:** R1. +- **Dependent decisions:** — +- **Referenced in plan:** Implementation Approach (Architecture and Integration Points), Decomposition and Sequencing. diff --git a/docs/plans/han-publishing-cleanup/artifacts/implementation-iteration-history.md b/docs/plans/han-publishing-cleanup/artifacts/implementation-iteration-history.md index 96cf1464..80f379c0 100644 --- a/docs/plans/han-publishing-cleanup/artifacts/implementation-iteration-history.md +++ b/docs/plans/han-publishing-cleanup/artifacts/implementation-iteration-history.md @@ -77,5 +77,12 @@ input. No specialist named an unengaged handoff (the test-engineer's optional `t team). The two independent reviews converged on the same structural call (C1/C20 — script-borne writes, one bearer) from opposite directions, which is the strongest signal available that the call is right. -**Decisions produced:** — _(backfilled at synthesis)_ -**Changed in plan:** — _(backfilled at synthesis)_ +**Decisions produced:** D-1 (C1, C20, OQ-B), D-2 (C2), D-3 (C3, C4), D-4 (C5), D-5 (C12), D-6 (C11, C16), D-7 (C9, C10), +D-8 (C6, C7), D-9 (C8), D-10 (C21), D-11 (C25, OQ-D), D-12 (C13, C14), D-13 (C15), D-14 (C17), D-15 (C18), D-16 (C19), +D-17 (C22, C23), D-18 (C24), D-19 (OQ-A), D-20 (C26, OQ-5), D-21 (spec D39), D-22 (edit-site D33/D7 boundary). All 22 +produced in this single round. + +**Changed in plan:** Outcome; Context; Team Composition and Participation; Implementation Approach (Architecture and +Integration Points, Data Model and Persistence, Runtime Behavior); Decomposition and Sequencing; RAID Log (Risks, +Assumptions, Dependencies); Testing Strategy; Operational Readiness; Definition of Done; Specialist Handoffs; Deferred +(YAGNI); Open Items; Summary. (Whole plan authored this round — phase 3's plan did not exist before R1.) diff --git a/docs/plans/han-publishing-cleanup/feature-implementation-plan.md b/docs/plans/han-publishing-cleanup/feature-implementation-plan.md new file mode 100644 index 00000000..853575c0 --- /dev/null +++ b/docs/plans/han-publishing-cleanup/feature-implementation-plan.md @@ -0,0 +1,390 @@ +# Feature Implementation Plan: Han Publishing Cleanup — Phase 3 + +Phase 3 ("Make a release bring every publishing target up to date") is implemented by moving the four-target repair, +creation, gap, and gate logic out of the release skill's prose and into one executable script the skill invokes +([D-1](artifacts/implementation-decision-log.md#d-1-the-publishing-writes-are-script-borne)), then editing the +`han-release` skill at the specific sites that today take their plugin list from a target they also write, commit two of +four targets, and place no gate. It ships the script and its bats tests; it does **not** turn the check on at the +pull-request surface (that is phase 6). + +## Source Specification + +- **Feature specification:** [feature-specification.md](feature-specification.md) +- **Specification decision log:** [artifacts/decision-log.md](artifacts/decision-log.md) +- **Specification team findings:** [artifacts/team-findings.md](artifacts/team-findings.md) +- **Specification technical notes:** [artifacts/feature-technical-notes.md](artifacts/feature-technical-notes.md) +- **Build phase outline:** [build-phase-outline.md](build-phase-outline.md) (Phase 3) +- **Specification decisions this plan inherits:** D1, D5, D6, D12, D14, D19, D24, D31, D34, D35, D36, D37, D38, D39, D41. +- **Specification open items this plan must respect or resolve:** OQ-5 (resolved — see + [D-20](artifacts/implementation-decision-log.md#d-20-oq-5-is-discharged-by-the-spec-as-option-a)); spec Open items 1–4 + shape phases 4 and 6 and do not touch phase 3. + +## Outcome + +After this phase, a Han release derives its plugin set from what is actually in the repository, brings all four +publishing targets up to date (correcting a drifted version, creating a missing channel-two listing entry), and refuses +to proceed when it meets a gap only a person can close — naming every gap at once, before anything is committed, tagged, +or pushed. The write logic lives in one executable bearer, `scripts/publishing-targets.sh` +([D-1](artifacts/implementation-decision-log.md#d-1-the-publishing-writes-are-script-borne)), which phase 6 will reuse +unchanged. The release skill runs the check and reports its answer verbatim rather than restating the rule. + +## Context + +- **Driving constraint:** Channel two drifted silently across eleven releases because the release takes its plugin list + from a file it also writes and commits only two of four targets. Phase 3 is the hinge of the cleanup: phase 4's hand + correction is durable only if the repair lands first, and phase 6's pull-request check reuses this phase's bearer. +- **Stakeholders:** the Han maintainer (a release that repairs all four targets and refuses real gaps rather than + shipping around them); contributors (phase 3's `CONTRIBUTING.md` addition makes phase 6's signal fair); channel-two + installers (reached by phase 4, made durable by this phase). +- **Future-state concern:** the check and the release must keep exactly one bearer as the suite grows + ([D-1](artifacts/implementation-decision-log.md#d-1-the-publishing-writes-are-script-borne)); a second copy of the rule + would reintroduce the drift by a different route. The plugin-set derivation must stay depth-bounded as + `han-plugin-builder` continues to teach authoring `plugin.json` files + ([D-13](artifacts/implementation-decision-log.md#d-13-derive-the-plugin-set-with-a-depth-bounded-jq-glob)). +- **Out-of-scope boundary:** the pull-request CI step and prek hook (phase 6); the hand version correction (phase 4); + the frontmatter description's stale plugin list, which this work does not falsify + ([D-22](artifacts/implementation-decision-log.md#d-22-leave-the-frontmatter-descriptions-stale-list-alone)); any + enforcement mechanism that would try to stop an agent skipping the check + ([D-17](artifacts/implementation-decision-log.md#d-17-state-the-enforcement-limit-and-print-findings-verbatim)). + +## Team Composition and Participation + +Medium plan (the release skill, a new check with two consumers, the docs it falsifies; no auth or PII surface). One +round; converged. Full round-by-round detail lives in +[artifacts/implementation-iteration-history.md](artifacts/implementation-iteration-history.md). + +| Specialist | Status | Key Input | +| --------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| `project-manager` | Coordinator | Facilitated R1 and synthesized this plan. | +| `junior-developer` | Active | Reframed OQ-5 (Option A), the created-entry field set, and the CONTRIBUTING correction; corrected the discovery-notes error on lines 138/157. | +| `devops-engineer` | Active | The gate placement, unconditional repair, recovery sequence, change-detection exclusion, bundle-identity derivation, staging pathspec. | +| `edge-case-explorer` | Active | The disk-fresh gate read, the jq null/parse traps, the depth-bounded derivation, the empty-plugin-set exit. | +| `test-engineer` | Active | The check contract (no mode flag, exit 0/1/2), synthetic-fixture testing, creation consuming the check's gap list. | + +## Implementation Approach + +The change has two parts: a new executable bearer at the repository root, and a set of targeted edits to the existing +`han-release` skill. Two independent reviews converged on the script-borne shape from opposite directions — the write +logic is what carries the bundle exception and the five-gap rule, and prose cannot be tested or trusted to gate +([D-1](artifacts/implementation-decision-log.md#d-1-the-publishing-writes-are-script-borne), +[D-17](artifacts/implementation-decision-log.md#d-17-state-the-enforcement-limit-and-print-findings-verbatim)). + +### Architecture and Integration Points + +**The bearer.** One new script, `scripts/publishing-targets.sh`, at the **repository root** (not inside any `han-*/` +plugin), with two argument-free verbs — `check` and `repair` +([D-1](artifacts/implementation-decision-log.md#d-1-the-publishing-writes-are-script-borne)). Root placement is repo +maintenance rather than any plugin's shipped behavior; a copy inside `han-*/` would ship dead weight to installers and +make every fix bump a version the check polices; and three callers (this skill, phase 6's CI step, the optional prek +hook) live in three trees. The ShellCheck hook covers a root script automatically (no `files` filter), and the +shebang-executable hook is satisfied by `chmod +x` in the same commit. It follows Betterment "The Book" Bash conventions +(`#!/usr/bin/env bash`, `set -euo pipefail`, ShellCheck clean, `mktemp` + traps, BSD/GNU portability) and the shape of +the `check-annotations.sh` precedent (multi-input, names every finding at once, one bearer with two callers). + +**The check contract.** No arguments; cwd is the repository root; exit `0` clean / `1` gaps found (all named at once) / +`2` cannot answer — an unreadable target, or an empty derived plugin set +([D-6](artifacts/implementation-decision-log.md#d-6-the-check-takes-no-mode-flag)). No mode flag: the release repairs +before the check runs, so the repair set and the gap set are exact complements, and the two surfaces differ by **when** +the check is called, not what it asks. + +**Plugin-set derivation.** Both verbs derive the plugin set from the repository by matching `*/.claude-plugin/plugin.json` +— a depth-bounded glob read with `jq`, not a recursive `find`, because `han-plugin-builder` teaches authoring +`plugin.json` files and a recursive walk would pick up authored examples +([D-13](artifacts/implementation-decision-log.md#d-13-derive-the-plugin-set-with-a-depth-bounded-jq-glob)). `jq` is +already granted in the skill's `allowed-tools`; `ls`/`find` are not, so a `jq`-driven glob keeps the permission surface +unchanged. The bundle (the permanent channel-two exception) is identified by deriving from +`.claude-plugin/marketplace.json`'s top-level `.name`, reusing the definition already at `SKILL.md:44-46` +([D-11](artifacts/implementation-decision-log.md#d-11-bundle-identity-is-derived-from-the-listing-name)). + +**Skill integration.** Add `Bash(./scripts/publishing-targets.sh:*)` to the `han-release` frontmatter `allowed-tools` +([D-19](artifacts/implementation-decision-log.md#trivial-decisions)). The skill edits touch these sites: + +| SKILL.md site | Today | Change | Decision | +| --- | --- | --- | --- | +| `:37-39` Project Context | Enumerates plugins via `jq -r '.plugins[] \| …' marketplace.json` (the defect verbatim) | Replace with the repo-derived, depth-bounded enumeration | [D-13](artifacts/implementation-decision-log.md#d-13-derive-the-plugin-set-with-a-depth-bounded-jq-glob) | +| `:48` Vocabulary | "children — every other entry in marketplace.json.plugins[]" | Rewrite (falsified by spec D5) | [D-22](artifacts/implementation-decision-log.md#d-22-leave-the-frontmatter-descriptions-stale-list-alone) | +| `:3-14` frontmatter `description` | Same stale plugin list as Vocabulary | **Leave alone** — not falsified by this work | [D-22](artifacts/implementation-decision-log.md#d-22-leave-the-frontmatter-descriptions-stale-list-alone) | +| `:142-146` Step 3 | "Enumerate the plugins from `plugins` in Project Context" | Enumerate from the repo-derived set | [D-13](artifacts/implementation-decision-log.md#d-13-derive-the-plugin-set-with-a-depth-bounded-jq-glob) | +| `:171-172` Step 3a | Change detection counts `{source}/.codex-plugin/plugin.json` | Exclude the channel-two record | [D-9](artifacts/implementation-decision-log.md#d-9-exclude-the-channel-two-record-from-change-detection) | +| `:237` Step 4 | Skips any plugin whose `target == current` | Skip stays for the record write; the repair (new) does not inherit it | [D-2](artifacts/implementation-decision-log.md#d-2-repair-iterates-the-derived-plugin-set-unconditionally) | +| new Step 4.5 | (no gate) | Invoke `check`; stop on non-zero, printing findings verbatim | [D-3](artifacts/implementation-decision-log.md#d-3-the-gate-is-a-new-step-45), [D-17](artifacts/implementation-decision-log.md#d-17-state-the-enforcement-limit-and-print-findings-verbatim) | +| `:326-328` Step 8.1 | Hand enumeration commits two of four targets | Stage by class pathspec | [D-18](artifacts/implementation-decision-log.md#d-18-stage-by-class-pathspec) | +| `:302-320` Step 7, `:350-356` Step 10 | Report the version plan only | Also report created and changed targets (spec D39) | [D-21](artifacts/implementation-decision-log.md#trivial-decisions) | + +### Data Model and Persistence + +The four targets are JSON files (two storefront listings, two per-plugin records). The only **created** artifact is a +channel-two storefront listing entry, and it is created inside the already-tracked `.agents/plugins/marketplace.json`. +Its exact field set carries no version +([D-7](artifacts/implementation-decision-log.md#d-7-the-created-channel-two-entry-carries-no-version)): + +```json +{ + "name": "{name}", + "source": { "source": "local", "path": "./{dir}" }, + "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" }, + "category": "Developer Tools" } +``` + +`policy` and `category` are the shared literals every existing entry already carries (measured 100% uniform). This is +the measurable form of spec D36's boundary: channel two's listing entry is the **only** target of the four with no +authored field in it, which is the one-line, checkable justification for the whole create-path +([D-7](artifacts/implementation-decision-log.md#d-7-the-created-channel-two-entry-carries-no-version)). Every other +target contains prose a person wrote, so a release stops rather than creating it. + +### Runtime Behavior + +`repair` iterates the derived plugin set **unconditionally**, writing the publishing version onto every writable record +and creating any missing channel-two listing entry — it does not inherit Step 4's `target == current` skip, because that +skip is the drift mechanism ([D-2](artifacts/implementation-decision-log.md#d-2-repair-iterates-the-derived-plugin-set-unconditionally)). +It **skips what it cannot safely write** and defers to the single gate, so the gate names the complete residue rather +than the repair aborting on the first gap +([D-5](artifacts/implementation-decision-log.md#d-5-repair-skips-what-it-cannot-safely-write)). Creation **consumes the +check's own gap list** rather than re-deriving it, making the bundle's *not-created* verb correct by construction +([D-10](artifacts/implementation-decision-log.md#d-10-creation-consumes-the-checks-gap-list)). Every cross-target join +keys on the plugin `name`, never the listing's `source` path +([D-14](artifacts/implementation-decision-log.md#trivial-decisions)). + +The gate is a **new Step 4.5**, immediately after the writes and before the non-idempotent changelog augment +([D-3](artifacts/implementation-decision-log.md#d-3-the-gate-is-a-new-step-45)). It **reads disk fresh** rather than the +skill's once-evaluated Project Context frontmatter, or it would check pre-repair state and always pass +([D-4](artifacts/implementation-decision-log.md#d-4-the-gate-reads-disk-state-fresh)). Reads guard the jq traps: `jq -r` +prints the literal `null` for both an explicit null and an absent key (so two broken values must not string-compare +equal), and a parse failure must not be masked as an empty array and route the whole channel into the create-path +([D-12](artifacts/implementation-decision-log.md#d-12-guard-the-jq-parse-and-null-traps)). + +On a gate stop, recovery is a **scoped `git restore` of the four tracked target paths**, never `git clean` — the +untracked-file hazard has zero members because creation only modifies an entry inside a tracked file +([D-8](artifacts/implementation-decision-log.md#d-8-recovery-is-a-scoped-git-restore-never-git-clean)). Spec D34's +separate-commit behavior stands on its other reason (a combined commit makes half-applied version writes look like +deliberate bumps). + +The skill prints the check's findings **verbatim** and includes the check's output in the always-printed Step 7 summary, +so the contributor's message equals the maintainer's and an agent that skipped the check has nothing to paste +([D-17](artifacts/implementation-decision-log.md#d-17-state-the-enforcement-limit-and-print-findings-verbatim)). Steps 7 +and 10 report what the release created and what it changed, alongside the version plan; a created listing entry names no +version ([D-21](artifacts/implementation-decision-log.md#trivial-decisions)). + +## Decomposition and Sequencing + +| # | Work Unit | Delivers | Depends On | Verification | +| --- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ---------- | ------------------------------------------------------------------- | +| 1 | `scripts/publishing-targets.sh` `check` verb + `chmod +x` | Depth-bounded derivation, five-gap detection, exit 0/1/2, bundle exception, jq-trap guards | Phase 1 | bats against synthetic fixtures; ShellCheck clean | +| 2 | `repair` verb | Unconditional writable-target repair + channel-two entry creation, skip-unsafe | 1 | bats against synthetic fixtures | +| 3 | Skill edits: Project Context, Step 3/3a, Step 4.5 gate | Repo-derived enumeration; codex-record change-detection exclusion; the gate call | 1, 2 | 7-step Phase 3 demo (Definition of Done) | +| 4 | Skill edits: Step 8.1 staging, Step 7/10 reporting, Vocabulary | Class-pathspec staging; created/changed reporting; falsified Vocabulary bullet rewrite | 3 | 7-step demo; diff review against edit-site table | +| 5 | `CONTRIBUTING.md` "Adding a plugin" section | Names the four targets so phase 6's signal is fair | — | Section present and names all four targets | +| 6 | `allowed-tools` frontmatter line | `Bash(./scripts/publishing-targets.sh:*)` | 1 | Skill invokes the script without a permission prompt | + +Work units 1–2 are the bearer; 3–4 are the skill wiring; 5–6 are the doc and permission edits. Phase 3's tests ship and +run under `npm test` with **no `ci.yml` change** — the check does not become visible on the pull-request surface here +([D-16](artifacts/implementation-decision-log.md#d-16-tests-use-synthetic-fixtures-phase-3-ships-script-and-bats-only)). +Phase 1 must have landed on the branch this work builds from, or the first release after phase 3 stops on the Linear +plugin's unwritten presence rather than repairing (spec D41). OQ-5 is discharged by the spec as Option A and struck from +the preconditions ([D-20](artifacts/implementation-decision-log.md#d-20-oq-5-is-discharged-by-the-spec-as-option-a)). + +## RAID Log + +### Risks + +| ID | Risk | Likelihood | Severity | Blast Radius | Reversibility | Owner | Mitigation | +| --- | ------------------------------------------------------------------------------------------------------ | ---------- | -------- | ------------------------- | ------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| R1 | An agent skips the check, or authors the missing record itself (Write/Edit granted) and re-runs green | Low | Medium | One release | High | devops-engineer | Cannot be prevented in principle; state the limit; print findings verbatim; include check output in the Step 7 summary ([D-17](artifacts/implementation-decision-log.md#d-17-state-the-enforcement-limit-and-print-findings-verbatim)) | +| R2 | An empty derived plugin set makes the check find zero gaps and exit green (invisible-by-construction) | Low | High | Every target, every plugin| High | edge-case-explorer | Empty plugin set exits `2` (cannot answer), not `0` ([D-6](artifacts/implementation-decision-log.md#d-6-the-check-takes-no-mode-flag)) | +| R3 | A listing parse failure is masked as "zero entries" and regenerates the whole storefront | Low | High | One channel's whole listing| High | edge-case-explorer | Distinguish parse failure from a parsed empty array; do not copy the skill's `2>/dev/null` idiom ([D-12](artifacts/implementation-decision-log.md#d-12-guard-the-jq-parse-and-null-traps)) | + +### Assumptions + +| ID | Assumption | What Changes If Wrong | Verifier | Status | +| --- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ----------------- | -------- | +| A1 | Phase 1 has landed on the build branch | The first release after phase 3 stops on the Linear plugin rather than repairing | maintainer | Open | +| A2 | Channel-two `policy` and `category` are uniform across all entries (creation constants) | The created entry needs per-plugin derivation | discovery JD-001 | Verified | +| A3 | All 11 plugin versions are plain `x.y.z` today (no prerelease/build metadata) | Version comparison needs semver prerelease handling | discovery notes | Verified | +| A4 | Exactly 11 directories match `*/.claude-plugin/plugin.json`, all real plugins | The depth bound admits a false positive or misses a real plugin | discovery notes | Verified | + +### Dependencies + +| ID | Dependency | Owner | Status | +| ---- | ---------------------------------------------------------------------- | ---------- | ------ | +| Dep1 | Phase 1 (Linear plugin's channel-two presence) landed on the build branch | maintainer | Open | + +## Testing Strategy + +Sourced from `test-engineer` and `edge-case-explorer`. Tests are bats, run via `npm test` (`bats --recursive test/`), +against **synthetic `mktemp -d` fixtures** — never the live repository tree, because a test asserting the 8 drifted +plugins flips green when phase 4 lands, and a green-check assertion against the real repo goes red on phase 3's own PR +and stays red through phase 4 ([D-16](artifacts/implementation-decision-log.md#d-16-tests-use-synthetic-fixtures-phase-3-ships-script-and-bats-only)). +Precedent: `test/check-annotations.bats` builds files under `mktemp -d`. + +- **Observable behaviors to test:** + - `check` exits `0` on a synthetic tree where all four targets agree. + - `check` exits `1` and names every gap at once on a tree with a missing listing entry, a missing record, a dangling + listing entry, an unreadable/indeterminate version, and a plugin with no derivable publishing version (the five-gap + list, spec D12/D36). + - `check` exits `2` on an unreadable target and on an empty derived plugin set + ([D-6](artifacts/implementation-decision-log.md#d-6-the-check-takes-no-mode-flag)). + - `repair` writes the publishing version onto every writable record and creates a missing channel-two entry with the + exact field set, no version ([D-7](artifacts/implementation-decision-log.md#d-7-the-created-channel-two-entry-carries-no-version)). + - `repair` leaves an unsafe-to-write target untouched and lets `check` name it + ([D-5](artifacts/implementation-decision-log.md#d-5-repair-skips-what-it-cannot-safely-write)). + - The bundle is neither flagged, asked to agree on channel two, nor created there + ([D-11](artifacts/implementation-decision-log.md#d-11-bundle-identity-is-derived-from-the-listing-name)). +- **Edge cases requiring coverage:** two broken (`null`) version values must not compare equal + ([D-12](artifacts/implementation-decision-log.md#d-12-guard-the-jq-parse-and-null-traps)); a listing parse failure + must exit `2`, not regenerate the storefront; the depth bound must not admit an authored `plugin.json` example + ([D-13](artifacts/implementation-decision-log.md#d-13-derive-the-plugin-set-with-a-depth-bounded-jq-glob)). +- **Test doubles posture:** no external CLI to stub for the script itself (it reads local JSON with `jq`); fixtures are + built on disk under `mktemp -d`. +- **Test levels:** unit/behavioral bats for the script; the release skill's prose-borne behavior is verified by the + 7-step Phase 3 demo (below), which cannot be unit-tested + ([D-17](artifacts/implementation-decision-log.md#d-17-state-the-enforcement-limit-and-print-findings-verbatim)). + +## Operational Readiness + +`devops-engineer` contributed. The change adds no runtime service, no telemetry, no traffic — it is a repo-maintenance +skill plus a check script. The operational surface is the release gate and its recovery. + +- **Rollout:** phase 1 precedes phase 3 (spec D41); phase 3 ships the script and bats tests under the existing + `npm test` with no `ci.yml` change; the CI step and prek hook are deferred to phase 6 + ([D-16](artifacts/implementation-decision-log.md#d-16-tests-use-synthetic-fixtures-phase-3-ships-script-and-bats-only)). + Forward note for phase 6: inherit `ci.yml`'s existing `push: main` trigger rather than narrowing to `pull_request`, + for free post-hoc detection of a skipped release gate + ([D-17](artifacts/implementation-decision-log.md#d-17-state-the-enforcement-limit-and-print-findings-verbatim)). +- **Gate signal:** the release gate at Step 4.5 is the operational signal; there is no external monitoring, deliberately + (deferred under YAGNI — the drift persisted because nothing asked the question, not because nobody watched a graph). +- **Recovery / rollback:** the whole phase reverts by reverting its commit (the script lands with the skill edits). A + gate stop recovers via a scoped `git restore` of the four tracked target paths, then the gap is corrected and + committed on its own, then the release is planned from scratch — never `git clean` + ([D-8](artifacts/implementation-decision-log.md#d-8-recovery-is-a-scoped-git-restore-never-git-clean)). + +## Definition of Done + +The bats and ShellCheck gates verify the script; the 7-step demo from build-phase-outline Phase 3 is the acceptance test +for the release skill's prose-borne behavior that cannot be unit-tested. + +- [ ] `scripts/publishing-targets.sh` exists at the repository root, is `chmod +x`, passes ShellCheck, and exposes + argument-free `check` and `repair` verbs ([D-1](artifacts/implementation-decision-log.md#d-1-the-publishing-writes-are-script-borne)). +- [ ] bats tests for the check contract (exit 0/1/2), the repair, the bundle exception, and the jq-trap edge cases pass + under `npm test` against synthetic fixtures ([D-16](artifacts/implementation-decision-log.md#d-16-tests-use-synthetic-fixtures-phase-3-ships-script-and-bats-only)). +- [ ] `CONTRIBUTING.md` has an "Adding a plugin" section naming all four targets; lines 138 and 157 are unchanged + ([D-15](artifacts/implementation-decision-log.md#d-15-add-an-adding-a-plugin-section-to-contributing)). +- [ ] The skill's Project Context and Step 3 enumerate from the repo-derived set; the frontmatter description's stale + list is left alone ([D-13](artifacts/implementation-decision-log.md#d-13-derive-the-plugin-set-with-a-depth-bounded-jq-glob), [D-22](artifacts/implementation-decision-log.md#d-22-leave-the-frontmatter-descriptions-stale-list-alone)). +- [ ] **7-step Phase 3 demo passes:** + 1. Point at a plugin whose channel-two version has fallen behind; note the number channel two publishes. + 2. Delete that plugin's channel-two listing entry (the shape a contributor leaves when they forget a target). + 3. Cut a release; watch it read the plugin list from the repository, name its version plan, and ask for confirmation. + 4. Watch it report what it changed and what it created — the stale version corrected, the missing entry recreated, + both named explicitly. + 5. See all four targets agree, and all four inside the commit the release tagged. + 6. Delete a plugin's channel-two **record** and cut a release; it stops before committing, names that gap and every + other gap in the same run, and publishes nothing. + 7. Add a fake listing entry for a plugin not in the repository and cut a release; it stops for that too rather than + deciding on its own. +- [ ] Post-ship owner: the Han maintainer. + +## Specialist Handoffs for Implementation + +- **`test-engineer`** — dispatch when writing the bats suite; needs the check contract + ([D-6](artifacts/implementation-decision-log.md#d-6-the-check-takes-no-mode-flag)) and the synthetic-fixture posture + ([D-16](artifacts/implementation-decision-log.md#d-16-tests-use-synthetic-fixtures-phase-3-ships-script-and-bats-only)). +- **`devops-engineer`** — dispatch when wiring the Step 4.5 gate and Step 8.1 staging; needs the recovery sequence + ([D-8](artifacts/implementation-decision-log.md#d-8-recovery-is-a-scoped-git-restore-never-git-clean)) and the staging + pathspec ([D-18](artifacts/implementation-decision-log.md#d-18-stage-by-class-pathspec)). + +## Deferred (YAGNI) + +### Enforcement wrapper (a script gating commit/push behind the check's exit code) + +- **Why deferred:** Evidence-test failure. Guards a class with zero observed members — the observed failure was prose + that was wrong, followed faithfully for eleven releases, not an agent skipping a step; and with Write/Edit granted it + cannot in principle stop the actor it targets. +- **Reopen when:** A release is cut where the gate was skipped, or the check's output is absent from the Step 7 summary. +- **Source:** R1, junior-developer. + +### Pre-flight check at Step 1 (so the operator is not stopped after approving a plan) + +- **Why deferred:** Evidence-test failure. The stop classes have zero live members; spec D24 commits to one gate; at + 4.5 a stop costs one scoped `git restore`. +- **Reopen when:** The gate stops a real release twice. +- **Source:** R1, devops-engineer. + +### Mode flag / `--json` / `--dry-run` / `--fix` / severity taxonomy on the check + +- **Why deferred:** Evidence-test failure (both callers need the same answer) and simpler-version test (an exit code + beats a taxonomy). +- **Reopen when:** A third caller needs a different answer. +- **Source:** R1, junior-developer and devops-engineer. + +### A version plan enumerating every target the release will touch (OQ-5 Option B) + +- **Why deferred:** Evidence-test failure. D39's report covers the notice; approving a derivable drifted-version + correction is approving arithmetic. +- **Reopen when:** A maintainer is surprised by a version the release wrote. +- **Source:** R1, junior-developer. + +### Tests of the SKILL.md prose + +- **Why deferred:** Evidence-test failure. There is no way to test that an agent follows an instruction, and a test that + cannot fail is worse than none. +- **Reopen when:** The enforcement-wrapper trigger fires. +- **Source:** R1, junior-developer and test-engineer. + +### Structured logging / metrics / monitoring on gate stops + +- **Why deferred:** Evidence-test failure. Nothing consumes them; solo maintainer, no telemetry, no runtime. The spec + already deferred external monitoring. +- **Reopen when:** Drift recurs despite the gate. +- **Source:** R1, devops-engineer. + +### A derivation path for a non-"Developer Tools" category or non-default policy on created entries + +- **Why deferred:** Evidence-test failure. All existing entries are 100% uniform; keep the constants and note the + assumption (A2). +- **Reopen when:** A plugin needs a different category or policy. +- **Source:** R1, edge-case-explorer. + +### Semver prerelease / build-metadata handling + +- **Why deferred:** Evidence-test failure. All 11 versions are plain `x.y.z` today (A3). +- **Reopen when:** A plugin adopts a prerelease scheme. +- **Source:** R1, edge-case-explorer. + +### Symmetric change-detection exclusion for `{source}/.claude-plugin/plugin.json` + +- **Why deferred:** Beyond phase 3's remit. It would also stop a description-only edit from bumping — a behavior change + with no measured instance. +- **Reopen when:** A description-only edit to `.claude-plugin/plugin.json` causes an unwanted bump. +- **Source:** R1, devops-engineer. + +## Open Items + +- **OI-1 (spec OQ-5):** What should a release do when the approved plan and a target's repair disagree? + - **Resolves when:** Resolved — Option A, discharged by the spec + ([D-20](artifacts/implementation-decision-log.md#d-20-oq-5-is-discharged-by-the-spec-as-option-a)). + - **Blocks implementation:** No — struck from the preconditions. +- **OI-2 (Dep1):** Phase 1 landed on the build branch (A1). + - **Resolves when:** The maintainer confirms phase 1 is present on the branch this work builds from. + - **Blocks implementation:** No — phase 3's code lands regardless; the first *release* after phase 3 stops on the + Linear plugin if phase 1 is absent, which is the spec's intended behavior (D41), not a defect. + +## Summary + +- **Outcome delivered:** a release derives its plugin set from the repository, repairs all four targets, and refuses + every gap only a person can close, via one executable bearer the pull-request check will reuse unchanged. +- **Team size:** 5 specialists (project-manager plus four) — see + [artifacts/implementation-iteration-history.md](artifacts/implementation-iteration-history.md) +- **Rounds of facilitation:** 1 — see + [artifacts/implementation-iteration-history.md](artifacts/implementation-iteration-history.md) +- **Decisions committed:** 22 — see [artifacts/implementation-decision-log.md](artifacts/implementation-decision-log.md) +- **Decisions settled by evidence:** 22 — see + [artifacts/implementation-decision-log.md](artifacts/implementation-decision-log.md) +- **Decisions settled by junior-developer reframing:** 0 (reframing fed evidence; all 22 resolved by evidence or + convergence) — see [artifacts/implementation-decision-log.md](artifacts/implementation-decision-log.md) +- **Decisions settled by user input:** 0 — see + [artifacts/implementation-decision-log.md](artifacts/implementation-decision-log.md) +- **Rejected alternatives recorded:** 25 — see + [artifacts/implementation-decision-log.md](artifacts/implementation-decision-log.md) +- **Open items remaining:** 2 (both non-blocking) +- **Recommendation:** Ship as planned. One round; converged. No blocking open item; OI-2 is a dependency the maintainer + confirms before the first post-phase-3 release, not a code blocker.