From bf8fc9c3a23c99a31fdd7a7a4883483a07198985 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Fri, 23 Oct 2026 16:00:05 -0400 Subject: [PATCH] feat(skills): add precompile design skills Adds motivation-check, design-plan, design-council and adr-writer, which take a change motivation through independent design plans to a point-form ADR. Retires changelog-grill, changelog-gap-check and technical-document; adr-writer keeps the technical-document style references. --- .claude/skills/adr-writer/SKILL.md | 221 +++ .../references/google-technical-writing.md | 0 .../simplified-technical-english.md | 0 .../references/template-point-form.md | 75 + .claude/skills/changelog-gap-check/skill.md | 124 -- .claude/skills/changelog-grill/SKILL.md | 41 - .claude/skills/design-council/SKILL.md | 173 +++ .../design-council/references/perspectives.md | 111 ++ .claude/skills/design-plan/SKILL.md | 256 ++++ .../references/global-invariants.md | 45 + .claude/skills/design-plan/references/lint.py | 1240 +++++++++++++++++ .../design-plan/references/plan-template.md | 144 ++ .../design-plan/references/plan_inputs.py | 260 ++++ .claude/skills/motivation-check/SKILL.md | 138 ++ .claude/skills/technical-document/SKILL.md | 249 ---- .../examples/generated-spec.md | 57 - .../examples/point-form-input.md | 49 - .../references/template-point-form.md | 40 - 18 files changed, 2663 insertions(+), 560 deletions(-) create mode 100644 .claude/skills/adr-writer/SKILL.md rename .claude/skills/{technical-document => adr-writer}/references/google-technical-writing.md (100%) rename .claude/skills/{technical-document => adr-writer}/references/simplified-technical-english.md (100%) create mode 100644 .claude/skills/adr-writer/references/template-point-form.md delete mode 100644 .claude/skills/changelog-gap-check/skill.md delete mode 100644 .claude/skills/changelog-grill/SKILL.md create mode 100644 .claude/skills/design-council/SKILL.md create mode 100644 .claude/skills/design-council/references/perspectives.md create mode 100644 .claude/skills/design-plan/SKILL.md create mode 100644 .claude/skills/design-plan/references/global-invariants.md create mode 100644 .claude/skills/design-plan/references/lint.py create mode 100644 .claude/skills/design-plan/references/plan-template.md create mode 100644 .claude/skills/design-plan/references/plan_inputs.py create mode 100644 .claude/skills/motivation-check/SKILL.md delete mode 100644 .claude/skills/technical-document/SKILL.md delete mode 100644 .claude/skills/technical-document/examples/generated-spec.md delete mode 100644 .claude/skills/technical-document/examples/point-form-input.md delete mode 100644 .claude/skills/technical-document/references/template-point-form.md diff --git a/.claude/skills/adr-writer/SKILL.md b/.claude/skills/adr-writer/SKILL.md new file mode 100644 index 00000000..d43138b8 --- /dev/null +++ b/.claude/skills/adr-writer/SKILL.md @@ -0,0 +1,221 @@ +--- +name: adr-writer +description: "Write a specification ADR from a case input, the chosen main design plan (template v2) and the other candidate plans. Produces the point-form changelog headings: a short decision-first Summary, a problem-only Motivation, a Background on how the affected contracts work today, Specs with one interface block and step-by-step behaviour changes, before/after Examples, plain-language alternatives and integrator Migration Steps. Use when asked to write, render or update an ADR or changelog entry from design plans." +version: 2.0.0 +--- + +# Write a specification ADR from design plans + +This skill turns a chosen design plan into an ADR. The ADR records a decision that was +already made. This skill does not design, choose a plan, change code or run a council. + +## Inputs + +- **`.council/input.md`**: holds only `## Motivation`, `## User flows to optimize for` and + `## Must not break`. It has no Summary. You write the Summary. +- **The main plan**: the plan the caller or architect chose. Use the path you are given. + Never pick the main plan yourself by file name, perspective or order. +- **The other plans**: the remaining candidates, if any. They supply the alternatives. +- **The code**, read only to confirm how the affected contracts behave today. + +Read every input in full before drafting. Treat all inputs as read-only. + +## Output + +Write the ADR to the path the caller gives. With no path given, write `.council/adr.md`. +Return or write only the ADR. Do not add transformation notes or a review section unless +the user asks for one. + +## Tool use + +Read files with the read tool (or hashread, whichever is available). Explore with the +grep, find and ls tools. Write the ADR with the write and edit tools. Do not use the +shell to read, copy or inspect files, and do not probe it. + +## Required reading + +Before drafting, read these files in full. Resolve the paths from this skill's directory. +They are the offline source for the format and the writing style; do not fetch websites +instead. + +```text +references/template-point-form.md +references/simplified-technical-english.md +references/google-technical-writing.md +``` + +The structure of the main plan is described in `../design-plan/references/plan-template.md`. + +## What an ADR is about + +The ADR is a specification for people who build on the protocol. It describes public +contracts, their observable behaviour, permissions, storage meaning, errors, events and +the effect on integrators. + +It never covers how a node or client implements the specification. Leave out the +implementation language, libraries or packages, client code, comparisons between a mock +and a node, internal source paths and lists of development work. You may read code to +learn facts; those facts still appear only as contract behaviour. + +## Voice + +Write as the author of the decision. State facts directly: "Existing callers see no +change", not "the plan says callers see no change". Never mention a plan, a plan section +or field, a source file of the inputs, a perspective name, a candidate number or how the +ADR was produced. Never write internal trace labels such as FLOW-n, CANT-n, BEH-n, ST-n, +G-INV-n or similar IDs, in prose, tables, code or diagrams. Describe the thing they label +in words instead. + +## Output structure + +Use the heading tree in `references/template-point-form.md`: the `#` title, optional +metadata bullets, then these headings in this order, with this spelling. + +```markdown +## Summary +## Motivation +## Background +## Specs +### Interface Changes +### Behavioural Changes +### Examples +## Design Decisions & Alternatives Considered +## Migration Steps +``` + +Keep every heading. You may add descriptive subheadings one level below a section (for +example one `####` per behaviour change, or one `###` per subsystem in Background). Do not +add other top-level sections. + +## Metadata + +Give the ADR a short, meaningful title and feature name. Fill a metadata field only when +you know its value: the date of writing, and `adr-writer` as the author. Omit any field you +cannot fill. Never write a placeholder, a TODO marker or an invented person. + +## Writing each section + +### Summary + +A few sentences that answer three questions and then end: + +1. What is decided: the change in one sentence. Use a short before/after contrast only + when it reads more clearly. +2. Who is affected, and whether they must do anything before the change activates. Base + this on the main plan's `breaking` and `activation` values and its interactions; do not + assume the change is non-breaking. +3. What does not change, where a reader could reasonably expect it to. + +Leave the reasons for the change to Motivation, rejected options to the alternatives +section and mechanics to Specs. + +### Motivation + +Rewrite the input's Motivation in your own clear prose. Cover: + +- the concrete situation that makes a change necessary; +- why today's behaviour cannot handle it, and what that leads to; +- what must become possible. + +Keep every supported fact, number and scope limit from the input. Do not describe the +solution, argue against other designs or add specification details. Do not copy the +input's flow or constraint labels. + +### Background + +Give a reader without context what they need to follow Specs: + +1. Introduce each affected contract as a whole: its role and who uses it. +2. Walk through the affected flow as it works today, from start to result: who calls + which function, which contract it reaches next, and where checks are enforced. +3. Add a Mermaid sequence or flow diagram when it makes the path easier to follow. + +Group facts under one subheading per subsystem when more than one is involved. Describe +only current behaviour, confirmed from the code or inputs; nothing invented. + +### Specs + +**Interface Changes.** Show one `solidity` block with every new and changed declaration +from the main plan's interface section, each with a brief comment saying whether it is new +or changed and what it does. Explain the declarations in sentences outside the block. A +small table may add detail, such as which role may call each function. Do not add +unchanged declarations or invent signatures. If the public interface does not change, say +so in one sentence. + +**Behavioural Changes.** Write one subsection per behaviour change, with a plain name. +Explain the logic in execution order: checks, authorization, errors, state updates and +events. Use numbered steps, a Mermaid flow diagram or step-by-step prose. Do not use large +tables for logic. Show storage changes as a readable table (field, type, where it lives, +what changes), keeping any layout or packing guarantee the plan states. Turn the plan's +scaffolding (`Functions:` lines, IDs) into normal prose. + +Use the reference part of the plan (requirement trace, new surface, hot-path cost, +invariants, interactions) to check that the Specs are complete and to state costs and +constraints. Do not reproduce those sections as tables. + +**Examples.** Build concrete call sequences from the plan's user flows and the verified +current behaviour. Prefer Solidity snippets and Mermaid diagrams. For each, show the setup, +the caller's action and the visible result, including a changed failure case when the +plan has one. Where possible, show the same task before and after the change and label +each version clearly. Do not invent APIs, values or a before version that does not exist. + +### Design Decisions & Alternatives Considered + +For each main choice, say what was chosen, what it gives the users and what it costs. +Where the design adds a bound or decides who may modify a new contract or record, explain +that choice and what a different choice would cost. + +Then describe each rejected option in a few plain words (for example "a separate registry +contract" or "store the list inline"), why it would help and why the chosen design wins. +Merge options that are in effect the same. Never name perspectives or candidates. Do not +invent a rejection reason the inputs do not support. If no other plans were supplied, say +that no alternative designs were weighed. + +### Migration Steps + +Address integrators: token issuers, applications, wallets and indexers. For each group +that is affected, state what they must do and what they may do, and when relative to +activation. Say plainly which groups need no action. Never list work for protocol or node +developers, such as implementation, testing, release or deployment tasks. Do not invent +rollout, rollback or storage-migration guarantees. + +## Fidelity rules + +- Preserve every fact, number, condition, qualifier and scope limit from the sources; + rephrase freely, but never change meaning. +- Keep uncertainty at the same strength. Carry relevant open questions into the section + they affect, as plain statements of what is not yet decided. +- Do not invent signatures, selectors, storage slots, gas numbers, roles, dates, people or + compatibility claims. + +## Style + +Apply `references/simplified-technical-english.md` and +`references/google-technical-writing.md`: name the actor, one action per sentence, plain +words, one term per concept, conditions before instructions, numbered lists for ordered +steps. Never claim official ASD-STE100 compliance. + +## Modes + +- **Live mode** (a user is present): if the main plan is not identified, or an essential + input is missing, ask once before drafting. Do not guess the main plan. +- **Eval mode** (run by a script): never ask questions or wait. Use the inputs and paths + given. If a fact is missing, say in the relevant section that it is not yet decided, or + leave the optional detail out. Never edit the input or the plans. + +## Checklist + +Re-read the sources, not only your draft, when you check. + +- [ ] Headings match the template in order; extra headings are only subheadings. +- [ ] Metadata holds only known values; no placeholders. +- [ ] No implementation language, client code, packages or developer tasks. +- [ ] No internal labels, perspective names or references to plans or the generation. +- [ ] Summary answers what is decided, who must act and what stays the same, and stops. +- [ ] Motivation states the problem only, with the input's facts intact. +- [ ] Background introduces each contract and walks through today's flow. +- [ ] One solidity interface block; one ordered subsection per behaviour change. +- [ ] Examples show before and after where possible. +- [ ] Alternatives use plain names and supported reasons. +- [ ] Migration steps address integrators only, with timing. +- [ ] No invented facts; input and plans unchanged; no questions in eval mode. diff --git a/.claude/skills/technical-document/references/google-technical-writing.md b/.claude/skills/adr-writer/references/google-technical-writing.md similarity index 100% rename from .claude/skills/technical-document/references/google-technical-writing.md rename to .claude/skills/adr-writer/references/google-technical-writing.md diff --git a/.claude/skills/technical-document/references/simplified-technical-english.md b/.claude/skills/adr-writer/references/simplified-technical-english.md similarity index 100% rename from .claude/skills/technical-document/references/simplified-technical-english.md rename to .claude/skills/adr-writer/references/simplified-technical-english.md diff --git a/.claude/skills/adr-writer/references/template-point-form.md b/.claude/skills/adr-writer/references/template-point-form.md new file mode 100644 index 00000000..ae62e5b8 --- /dev/null +++ b/.claude/skills/adr-writer/references/template-point-form.md @@ -0,0 +1,75 @@ +# ADR template and source mapping + +The ADR uses the point-form changelog structure. Keep every heading below, with this +spelling, level and order. Bracketed text is an instruction; replace it, never copy it. + +## Heading tree + +```markdown +# [Short name of the decision] + +- **Feature Name**: [short name] +- **Start Date**: [date of writing, if known] +- **Authors**: adr-writer + +## Summary + +## Motivation + +## Background + +### [Subsystem name, optional] + +## Specs + +### Interface Changes + +### Behavioural Changes + +#### [Plain name of one behaviour change] + +### Examples + +## Design Decisions & Alternatives Considered + +## Migration Steps +``` + +Subheadings in brackets are optional and may repeat. Do not add top-level sections such as +`Goals`, `Risks`, `Rollout` or `Appendix`; put that content in the closest section above. + +## Metadata + +- The `#` title and **Feature Name** use the same short name for the change. +- Include **Start Date** and **Authors** only when the value is known. Omit a field rather + than write a placeholder. Never invent a person or a date. + +## Section sources + +Read the main plan (template v2) as follows. Its section numbers guide you while drafting; +they never appear in the ADR. + +| ADR section | Draw from | +| --- | --- | +| Summary | Approach, changes, `breaking`, `activation`, interactions | +| Motivation | The input's Motivation, rewritten as a problem statement | +| Background | Code you read, the input, interactions | +| Interface Changes | Interface block (new and changed declarations) | +| Behavioural Changes | Each behaviour change and the storage subsection | +| Examples | User flows and verified current behaviour | +| Design Decisions & Alternatives Considered | Approach, what the plan gives up, the other plans | +| Migration Steps | Changes, interactions, `breaking`, `activation` | + +The reference part (requirement trace, new surface, hot-path cost, invariants, +interactions) is for checking completeness and stating costs. It is not copied into the +ADR, and its IDs never appear. + +## Formatting + +- Interface Changes: one `solidity` block. Each declaration carries a short comment that + says new or changed and what it does. Prose explains it outside the block. +- Behavioural Changes: one subheading per change; logic as numbered steps, a Mermaid flow + or step-by-step prose. Storage as a small readable table. +- Examples: Solidity snippets and Mermaid diagrams, labelled "Before" and "After" when both + exist. +- Prefer prose and short lists. Use tables only for small, regular facts. diff --git a/.claude/skills/changelog-gap-check/skill.md b/.claude/skills/changelog-gap-check/skill.md deleted file mode 100644 index 13db8ac7..00000000 --- a/.claude/skills/changelog-gap-check/skill.md +++ /dev/null @@ -1,124 +0,0 @@ -# changelog-gap-check - -Check a changelog entry for progressive-discovery ordering and content quality. - -## When to use - -Invoke with `/changelog-gap-check ` where `` is the absolute or repo-relative path to a changelog markdown file (e.g. `changelog/02_Cobalt_B20_seize.md`). - -## What this skill does - -This is a **read-only audit**. It produces a report with reorder suggestions and consolidation recommendations — it does not modify the file. - -### 1. Progressive-discovery check - -For each section (delimited by `##` headings), scan the bullet points and sentences in order. A concept (identifier, acronym, named entity) must be **introduced before it is referenced**. If sentence/bullet N references concept X but concept X is first defined or introduced at sentence/bullet M where M > N, flag it as an ordering violation. - -**How to detect:** -- Extract named concepts: function names, role names, policy names, event names, error names, acronyms, and domain terms that are not common English. -- For each concept, record its first-mention position (section + bullet/sentence index). -- If a reference appears before its introduction within the same section, report it. - -**Output format for each violation:** -``` -REORDER: section "" - - "" (line ~N) - references `X`, first introduced at line ~M ("") - → Suggest: move introduction before first reference -``` - -### 2. Duplicate/redundant content check - -Scan across all sections for bullets or sentences that convey the same information. Flag pairs where: -- The same fact is stated in two places (even with different wording). -- A later bullet restates what an earlier bullet already covered. - -**Output format:** -``` -DUPLICATE: - - line ~N: "" - - line ~M: "" - → Suggest: consolidate into a single bullet, keep in whichever section introduces it first -``` - -### 3. Point-form style recommendations - -For any paragraph-style prose that could be tightened into point-form technical English, suggest a rewrite. Technical English means: -- Active voice, present tense -- One fact per bullet -- No filler words ("it should be noted that", "in order to", "basically") -- Named subjects (not "it" or "this" without antecedent) - -**Output format:** -``` -STYLE: line ~N - Original: "" - Suggested: - - - - -``` - -### 4. Motivation gap detection (what without why) - -Scan for statements that introduce a NEW capability, behavior change, or design decision without explaining the problem it solves or the reason it exists. - -**Symptoms to flag:** -- "allows X to Y" / "enables X" / "adds support for X" without a preceding or following "because" / "so that" / problem statement -- Specs sections that describe HOW a mechanism works (storage layout, gas, ordering, event sequence) without stating WHY it matters to the reader -- Design-decision bullets that state a choice without rationale ("we chose X" with no "because Y") - -**What is NOT a gap (do not flag):** -- A feature whose motivation is explained earlier in the same document (Summary or Motivation section) does not need re-explanation in Specs — unless the Specs section exceeds ~30 lines, at which point a reader skimming only that section loses context -- Standard interface conformance ("ERC-8056 conformant") is a valid WHY when the standard is linked or named -- Backwards-compatibility statements ("unchanged for back-compat") are self-motivating -- Q&A pairs in a "Guarantees and edge cases" section — these answer "how does it behave?" which is their purpose - -**Output format:** -``` -WHY-GAP: line ~N, section "" - Statement: "" - Missing: motivation / problem statement — why does this capability exist? - Hint: -``` - -**Example:** -``` -WHY-GAP: line ~12, section "Summary" - Statement: "The new scheduled path allows issuers to define a new multiplier in advance and activate it at a specific future timestamp." - Missing: motivation — why do issuers need advance scheduling rather than instant updates? - Hint: State the operational driver (e.g., corporate actions require advance notice to exchanges/custodians before the multiplier flips). -``` - -## Output structure - -``` -# Changelog Audit: - -## Progressive Discovery -### Section: - - -## Duplicates - - -## Style Suggestions - - -## Motivation Gaps - - -## Summary -- Ordering violations: N -- Duplicate pairs: N -- Style suggestions: N -- Motivation gaps: N -``` - -## Rules - -- Do NOT edit the file. Report only. -- If the user passes `--fix`, then apply the suggested reorderings and consolidations directly to the file. Still output the report showing what changed. -- Treat markdown tables as atomic units — do not reorder rows within a table for progressive discovery (tables have their own sort order conventions). -- Ignore the audience blockquote (`> **Audience:**...`) — it's introductory framing, not subject to reorder. -- Cross-section references are fine: a concept introduced in an earlier section can be referenced in a later section without violation. -- Only flag ordering issues within the same `##` section. diff --git a/.claude/skills/changelog-grill/SKILL.md b/.claude/skills/changelog-grill/SKILL.md deleted file mode 100644 index 385b0006..00000000 --- a/.claude/skills/changelog-grill/SKILL.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -name: changelog-grill -description: Interview the user relentlessly about a changelog or ADR template until reaching shared understanding. Proposes point-form edits after each round. ---- - -# Changelog Grill - -Interview me relentlessly about every aspect of this plan until we reach a shared understanding. - -## Setup - -1. Read the template file (path provided by user, or pasted content). -2. Read `changelog/TEMPLATE_POINT_FORM.md` for expected structure. -3. Inspect related source, interfaces, tests, and existing changelog entries to verify facts. - -## Protocol - -Walk down each branch of the design tree resolving dependencies between decisions one by one. - -- Ask exactly ONE question per message. Never ask two or more questions in the same output. -- If the question can be answered by exploring the codebase, explore the codebase instead of asking. If there are key details missing in the template suggest them. -- For each question, provide your recommended answer. -- After the user responds, apply the edit to the point-form template immediately, then ask the - next single question. - -Question format: - -``` -❓ ****: <question + context> -➡️ **Recommendation:** <your recommended answer and why> -``` - -## After each answer - -Apply the result directly to the point-form template file. Edits must use point-form technical -English: active voice, present tense, one fact per bullet, named subjects, state WHY alongside WHAT. - -## Completion - -Done when every section has been reviewed, no assumption remains silent, and the user confirms -shared understanding. diff --git a/.claude/skills/design-council/SKILL.md b/.claude/skills/design-council/SKILL.md new file mode 100644 index 00000000..ca9cd93f --- /dev/null +++ b/.claude/skills/design-council/SKILL.md @@ -0,0 +1,173 @@ +--- +name: design-council +description: "Run several independent designer passes on the same change, each from a different perspective, and choose one plan. Live mode runs the passes in isolation, shows the plans side by side and lets the architect pick or override; eval mode leaves running and picking to an external runner. Use when asked to convene a design council, compare design options or produce competing design plans." +version: 1.1.0 +--- + +# Design council + +Produce several complete, independent design plans for one change, each from a +different perspective, so the decision maker can compare real alternatives. This skill +coordinates the passes and, in live mode, the comparison. It does not implement code +and does not write an ADR. + +## Tool rules for designer passes + +Every designer pass follows the tool rules of the design-plan skill, before anything +else: read files with the read tool (or hashread, whichever is available), explore with +the grep, find and ls tools, write the plan with the write and edit tools, and use bash +only for design-plan's exact self-lint command, from the workspace root, with its +canonical paths and flags. No shell exploration, no shell probes (not even `echo`, +`true` or `pwd`), no wrappers and no alternate plan paths. + +Setting up working copies and collecting finished plans is the coordinator's job (or +the external runner's), never something a designer does through its shell. + +## Inputs + +- **`.council/input.md`**, unchanged. It holds only `## Motivation`, + `## User flows to optimize for` (FLOW-n) and `## Must not break` (CANT-n). There is no + Summary; do not require or write one here. +- **The same code snapshot** for every pass. +- **The design-plan skill** (`../design-plan/SKILL.md`, resolved from this skill's + directory) with its bundled references: plan template, global invariant, linter and + input parser. Do not copy them here. +- **Perspectives** in `references/perspectives.md` (resolved from this skill's + directory): `minimal-change` plus three other lenses, one `## <id>` block each. A pass + receives exactly one block. + +## Live mode (an architect is present) + +### 1. Run the passes independently + +1. Run one designer pass per perspective in `references/perspectives.md`, always + including `minimal-change`. +2. Give each pass a fresh context and its own working copy with identical input and + code. Supply only the perspective id, its block and the shared inputs. Do not show a + pass any other candidate, draft or conclusion until all passes have finished. +3. Each pass follows design-plan, writes its own `.council/plan.md` in its own copy and + repeats the self-lint until it exits 0. +4. Passes may run in parallel or one after another; either way each needs a fresh + context and an isolated output. If isolation is not possible, say so plainly instead + of calling the passes independent. +5. When a pass finishes, the coordinator keeps its plan under the perspective id (for + example `.council/plans/<id>.md` in the live workspace). Designers never write to a + shared output file. + +### 2. Compare side by side + +Only after every pass has finished, show the candidates in one table or parallel +sections with, for each: + +- perspective id, plan id and the path to the full plan, so the architect can open any + candidate in full; +- the approach in one or two sentences; +- how well it serves each active FLOW-n and requirement from the Motivation, not only + whether it covers them: what the caller has to do, the setup and update effort, the + capacity the flow actually gets, the trust it asks for and its recurring cost; +- whether every CANT-n and the global invariant still hold, and anything left uncertain; +- each new limit with the value chosen, the flow evidence behind it and the cost of a + lower or a higher value; +- for every contract or stored record the design adds, which party (if any) may modify + it once it exists and, when none may, the price of deploying a replacement and + updating everything that points at the old one; +- added public surface (functions, storage, errors, events); +- worst-case cost on the hot paths; +- compatibility and activation impact; +- main trade-offs and open questions; +- lint status. + +Point to the plan sections and change ids (BEH-n, ST-n) behind each entry instead of +restating them. + +A clean lint run shows structure only, not that the design is correct, and a small +surface count shows economy, not fitness. Say which claims you checked against the +code and which are assumptions. Report unmet constraints, uncertain claims and failed +or incomplete passes visibly and separately; do not drop them silently or present them +as successes. + +You may add a recommendation. Keep it apart from the table, give its reasons and its +costs, and leave the choice to the architect. Base it on how well each candidate +serves the user flows and requirements, weighed against what it costs: + +- `minimal-change` is the reference point the other candidates are measured against. It + wins when the evidence shows it serves the flows best, never by default and never as + a tie-break only because it adds less. +- A larger candidate earns its extra surface only through a concrete, evidenced benefit + to a stated flow or requirement, not through speculative features or headroom. +- Treat a candidate that serves a flow only with heavy caller effort, a capacity below + what the flow needs, or a hidden trust assumption as weaker on that flow, even if it + technically covers it. + +### 3. The architect decides + +Ask the architect to do one of the following: + +- **select** a named candidate (the recommended one or another); +- **override** by asking for specific revisions, or for a combination of candidates; +- **reject** all candidates and say what is missing. + +Wait for an explicit answer. Silence, list order, a clean lint run or your +recommendation is not approval. If no candidate is acceptable, go back to revision +(new or revised passes) instead of inventing a winner. Never offer a failed, incomplete +or constraint-violating plan as ready for handoff. + +Overrides: + +- Selecting a different complete candidate changes the selection only, never that + plan's content. +- An override that changes content produces one complete, coherent revised plan in the + plan template, not a patch list or a union of incompatible fragments. Keep + traceability, the BEH-n and ST-n changes, FLOW-n and CANT-n coverage, interface + declarations, surface counts, breaking and activation metadata, and code-backed + claims consistent across the whole plan. +- A revision is a designer pass: it writes `.council/plan.md` in its own working copy, + runs design-plan's exact self-lint until it exits 0, and then the content review. Only + the coordinator collects and stores the result. +- Show the architect what changed and how the trade-offs moved, and get an explicit + confirmation of the revised plan. +- An override never waives a requirement, a FLOW-n, a CANT-n or the global invariant. + +### 4. Finalize and hand off + +1. Keep every original candidate under `.council/plans/<id>.md` unchanged. +2. Once the architect has confirmed a lint-clean plan, the coordinator copies that exact + plan to `.council/plan.md` in the live workspace with the file tools. Never silently + overwrite a different selection that is already there. +3. In the reply, record the chosen plan id and perspective, the final path, the + architect's confirmation, the rationale and trade-offs the architect stated, and, + for a revised plan, which candidates it came from. Do not add selection notes to + the plan itself, and do not invent reasons the architect did not give. +4. Hand off to the adr-writer skill (`../adr-writer/SKILL.md`) with explicit inputs: + the unchanged `.council/input.md`, the confirmed main-plan path, and the paths of + the other complete candidates as alternatives (for a revised plan, keep its source + candidates among them). List failed or incomplete passes separately; they are not + alternatives. +5. The council stops here. The adr-writer writes the Summary and the ADR. + +## Eval mode (no user, run by a script) + +An external runner sets up the independent passes, gives each one perspective, collects +the plans and picks the plan. Inside an assigned pass: + +- follow design-plan with the given perspective and finish with a lint-clean + `.council/plan.md`; +- do not start a nested council, compare candidates or pick a winner; +- never ask questions, prompt a user or wait for approval; +- record unresolved facts as uncertainties in the plan; never invent them. + +## Checklist + +- [ ] Every perspective in `references/perspectives.md` ran, including `minimal-change`. +- [ ] Each pass had a fresh context, an isolated copy and identical inputs. +- [ ] Every candidate is lint-clean, or its failure is reported. +- [ ] Live mode: any recommendation rests on flow and requirement fit, with + `minimal-change` as the reference point rather than the default. +- [ ] Live mode: the comparison shows each added bound with its evidence and costs, and + the modification rights over each added contract or record. +- [ ] Live mode: the comparison came after all passes, and the architect explicitly + selected, overrode or rejected; no approval means no handoff. +- [ ] Live mode: any revised plan was lint-clean, reviewed again and confirmed. +- [ ] Live mode: original candidates are preserved, and the handoff names one main + plan and the other complete candidates. +- [ ] Eval mode: no questions, no nested council, no selection inside a pass. diff --git a/.claude/skills/design-council/references/perspectives.md b/.claude/skills/design-council/references/perspectives.md new file mode 100644 index 00000000..f028bd7f --- /dev/null +++ b/.claude/skills/design-council/references/perspectives.md @@ -0,0 +1,111 @@ +# Design perspectives + +Each block below is one design lens for a single designer pass. A caller may hand a +designer just one block, so every block stands on its own and repeats the rules that +bind every lens. A lens decides which trade-offs to favour. It never adds requirements, +drops a user flow or relaxes a must-not-break item. If the evidence leads two lenses to +the same design, say so; do not invent differences. + +## minimal-change + +Objective: the smallest change that fully serves every requirement and user flow and +keeps every must-not-break item. + +- Binding for this lens: meet every requirement, cover every FLOW-n and CANT-n in the + input, and respect the global invariant. +- Prefer mechanisms that already exist in the code (storage, roles, errors, events, + dispatch paths) over new ones. New public surface needs a requirement or flow that + cannot be served well without it. +- Measure size as added public functions, storage slots, errors, events and touched + files, plus churn in existing callers. +- Limits: if the design introduces a count, size, depth or similar bound, take its value + from what the flows need, and state what a lower and a higher value would cost. Small + is not a reason to pick the minimum. +- New on-chain objects: weigh an owner or admin, the creator, or nobody as the party who + may change the object after creation. Do not pick immutability just to avoid a setter + or a role; count the cost of replacing the object and re-pointing every consumer. +- Evidence to examine: what the current interfaces and storage already support, and + which gaps no existing mechanism can close. +- Trade-offs accepted: may leave some inefficiency or awkwardness in place if fixing it + is not required; must say where a slightly larger change would give a flow a concrete + benefit or remove a real risk. +- Not acceptable: meeting a requirement or serving a flow only partly to keep the diff + small. + +## hot-path-efficiency + +Objective: keep the recurring cost of the most frequent operations as low as possible in +the worst case, without making the user flows harder to carry out. + +- Binding for this lens: meet every requirement, cover every FLOW-n and CANT-n in the + input, and respect the global invariant. +- Count storage reads and writes, external or cross-contract calls and loops on each hot + path, before and after the change, and name the worst case (for example the deepest + nesting or the longest list a caller can create). +- Prefer bounded, constant-cost checks on the hot path; move work to rarer + administrative paths when that keeps semantics intact. Count the setup and update + cost those paths then carry, not only the frequent call. +- Limits: size every bound from what the flows need, backed by evidence, and state the + cost of a lower value (flows that no longer fit) and a higher value (worst-case cost). + Do not invent usage numbers or add headroom nobody asked for. +- New on-chain objects: state who may change each one after creation (owner or admin, + creator, or nobody) and what each option costs the flows, including replacing an + immutable object and re-pointing every consumer. +- Evidence to examine: the code of the hot paths as they are today and how often each + path runs relative to configuration paths. +- Trade-offs accepted: more implementation complexity, extra bookkeeping on write paths + or explicit limits, when the code shows they lower the recurring cost. Every cost claim + must be traced to code, not guessed. +- Not acceptable: unbounded work that any caller can trigger on a frequent path. + +## compatibility-first + +Objective: the least disruption to existing callers, stored data, integrations and the +way the change is activated, while fully serving the new user flows. + +- Binding for this lens: meet every requirement, cover every FLOW-n and CANT-n in the + input, and respect the global invariant. +- Keep existing function signatures, return values, errors, events and storage layout + unchanged where possible; add rather than alter. +- Check every existing caller, mock and test that depends on current behaviour, and + state how already-stored data is read after the change. +- Describe activation: what happens before and after the change takes effect, and + whether any caller must act. +- Limits: derive any new bound from the flows' needs and state what a lower and a higher + value would cost, including whether raising it later would disrupt callers. +- New on-chain objects: compare an owner or admin, the creator, or nobody as the party + who may change the object after creation. Treat creator and admin as different trust + models, and count the burden on existing consumers if an immutable object has to be + replaced and every reference re-pointed. +- Evidence to examine: public interfaces, storage slot derivation, existing tests and + any documented behaviour callers rely on. +- Trade-offs accepted: extra code paths, adapters or duplicated entry points when they + keep existing users working without changes. +- Not acceptable: silent behaviour changes for existing callers or stored data. + +## auditability-first + +Objective: make state transitions, authority boundaries and invariant reasoning easy to +inspect, while fully serving the user flows. + +- Binding for this lens: meet every requirement, cover every FLOW-n and CANT-n in the + input, and respect the global invariant. +- Make each state change explicit and single-purpose, with clear authorization checks + and an event for every change an observer needs to follow. +- Prefer designs where each invariant can be checked locally (one function, one slot) + over designs that need whole-system reasoning. +- Give every failure a specific error, and explain from the code why each invariant and + each must-not-break item holds. Read existing tests for evidence; the plan itself + proposes no tests. +- Limits: state each new bound, why the flows need that value, and what a lower and a + higher value would cost. +- New on-chain objects: compare an owner or admin, the creator, or nobody as the party + who may change the object after creation, and state exactly who may change what. + Immutability is not automatically the safest choice: weigh it against the flows and + the cost of replacing the object and re-pointing every consumer. +- Evidence to examine: role and permission checks, error and event definitions, and how + existing tests express invariants. +- Trade-offs accepted: some additional code, events or validation when they reduce the + reasoning needed to trust the change. +- Not acceptable: hidden side effects, implicit authority, or state reachable only + through undocumented paths. diff --git a/.claude/skills/design-plan/SKILL.md b/.claude/skills/design-plan/SKILL.md new file mode 100644 index 00000000..9a458dfe --- /dev/null +++ b/.claude/skills/design-plan/SKILL.md @@ -0,0 +1,256 @@ +--- +name: design-plan +description: "Run one designer pass from a given perspective: read .council/input.md, the global invariant and the current code, then write .council/plan.md in the plan template with every requirement traced to a FLOW-n or a Motivation sentence, every FLOW-n and CANT-n covered, and the plan lint-clean. Use when asked for a single design plan for a precompile change." +version: 2.0.0 +--- + +# One designer pass + +Write one design plan, from one perspective, for the change described in +`.council/input.md`. The output is `.council/plan.md`. This skill does not compare +plans, pick a winner, write an ADR or implement the change. + +## Tool use + +Work in the workspace copy with file tools, and keep the shell for one command only. + +- Read files with the read tool (or hashread, whichever is available). Explore with + the grep, find and ls tools: the input, the bundled references and the source code. +- Create and update `.council/plan.md` with the write and edit tools. +- Use bash for exactly one command, the self-lint, run from the workspace root and + written exactly like this: + + ```sh + python3 -B .claude/skills/design-plan/references/lint.py .council/plan.md --input .council/input.md + ``` + + Repeating that same command after fixing the plan is fine. +- Run no other shell command: no cat, sed, grep, ls, cp, mkdir, git, Python scripts, + heredocs, wrappers, variable assignments, pipes, redirects, chained commands or + extra flags. Do not use the shell to find the working directory either. +- Never probe or test the shell, not even with `echo`, `true` or `pwd`. The only bash + command in a designer pass is the self-lint command above. + +## Inputs + +- **Perspective.** A design lens supplied by the caller (for example "smallest change + that meets every requirement"). It shapes which design you choose. It never permits + dropping a requirement, a user flow or a must-not-break item. If no perspective is + given, use `minimal-change`. +- **`.council/input.md`.** It holds only three sections: `## Motivation`, + `## User flows to optimize for` (FLOW-n) and `## Must not break` (CANT-n). There is + no Summary; do not require one and do not invent one. +- **References bundled with this skill** (resolve them against this skill's directory, + not the workspace): + - `references/plan-template.md`: the exact plan structure. + - `references/global-invariants.md`: `G-INV-1`, which applies to every plan. + - `references/lint.py`: the structural linter. + - `references/plan_inputs.py`: the input parser the linter imports from its own + directory. +- **The code.** The current contracts, precompile implementation, mocks and tests in + the target workspace. + +Never edit `.council/input.md` or the source code. Keep every ID from the input, +including retired items. + +## Modes + +- **Live mode** (a user is present): ask a question only when an essential fact is + still unresolved after reading the input and the code. +- **Eval mode** (no user, run by a script): never ask questions and never wait for an + answer. Resolve what the code supports. State what remains uncertain, and its + impact on the design, in §6 Open questions. Do not invent facts. + +## Workflow + +### 1. Read before drafting + +1. Read `.council/input.md` completely. List every Motivation sentence, every FLOW-n + and every CANT-n, and note which items are retired. +2. Read `references/plan-template.md` and `references/global-invariants.md`. +3. Read the code the change touches: public interfaces, storage layout and slot + derivation, authorization roles, errors and events, the hot paths (`transfer`, + `transferFrom`, `mint`, `burn`), the Solidity mock and the Rust precompile, and the + existing tests. Find out what already exists before you add anything; reuse beats + new surface. +4. Record which input and code snapshot you read in `inputs_ref`, using what the + caller supplied or what the files show. Do not run git to find it, and do not + invent snapshot data. + +### 2. Choose the design + +Pick the design that the perspective favors and that still meets every requirement. +Prefer reusing existing functions, roles, storage, errors and events. Check the design +against `G-INV-1` now: the precompile must not call a non-precompile contract while it +carries out the change. Calls between precompiles are allowed, and the rule does not +restrict who calls the precompile. + +Two decisions need explicit reasoning under every perspective. A lens never excuses +skipping them, and a design that adds no limit or no new object does not invent one. + +**Limits.** When the design adds a bound (a maximum count, size, depth, length or +similar), size it from the user flows: + +1. Name the active flows that consume the bounded resource and what each needs in + practice (for example how many entries a typical caller needs). +2. Pick the value that serves those needs, citing the input or code that shows them. +3. Say what a lower value would cost: which supported uses fail or need extra calls. +4. Say what a higher value would cost: extra storage, loop iterations, gas or attack + surface on the affected paths. + +Do not choose the smallest value that works only because it keeps the surface or the +code small. A small value is fine when the flows show it is enough; equally, do not add +headroom no flow asks for. If a usage fact is unknown, handle it as the mode says +instead of inventing numbers. + +**Who can change a new object.** For every new on-chain object (a record, entry, +configuration or similar that lives in storage after it is created), decide who may +modify it afterwards and what they may modify. The options are: + +- an owner or admin role; +- the creator of the object; +- nobody: the object is immutable once created. + +Weigh each option against the flows: who needs to update the object, who should be +trusted to, and what new authority each option brings. Creator control and admin +control are different trust models; do not treat them as interchangeable. For an +immutable object, count the cost of change: a replacement must be created and every +consumer that references the old one must be re-pointed, which takes one call per +consumer by whoever controls it, and some consumers may not be re-pointable at all. +Choose on fit with the flows and constraints, not on a general preference for +mutable or immutable data. Add a setter or role only when the chosen option needs it. + +Carry both decisions into the plan: state the chosen value and authority with their +reasons in §1, enforce them in the numbered steps of §3, show their effect in the +affected §4 flows, record the lower/higher and rejected authority options with their +costs in §5, and reflect the resulting costs and consumers in §9 and §11. + +### 3. Write `.council/plan.md` + +Read `references/plan-template.md` and write its structure into `.council/plan.md` +with the write tool; do not copy it with a shell command. + +- Keep the front matter keys, the title, all eleven numbered headings, the `# Reference` + heading between §6 and §7, the `### Storage` subheading, every table header and their + order exactly as written. +- Fill the front matter: `plan_id`, `perspective`, `inputs_ref`, `breaking`, + `activation` and the six `surface` counts. +- Replace every placeholder; remove the instruction comments and sample rows. No `TBD`. +- A table with no rows keeps its header and gets a line `None: <reason>` under it. The + requirement trace, hot-path and invariant tables always have rows. +- Keep the body at or under 3500 words. Be concrete; do not pad. + +#### How to write it + +The plan has two parts. Sections 1 to 6 are read by people who want to understand the +design quickly. Sections 7 to 11 are the reference part, checked line by line. + +- Use concise technical English: short sentences, one idea each, active voice, the + same term for the same thing throughout. +- Explain with prose, code and numbered steps. Add a mermaid diagram (```mermaid) + when a flow between callers, the precompile and storage is easier to see than to + read; the diagram supplements the numbered steps, it does not replace them. +- Avoid large tables in sections 1 to 6. A table there has at most six body rows; the + Storage table is the only exception. +- There is no edge-case or test-plan section. Do not add one, and do not name tests in + the trace or invariant tables. + +#### Readable part (§1 to §6) + +- **§1 Approach.** A few short paragraphs: what the design does, and why it meets the + Motivation, the user flows and the must-not-break items. Name the main mechanism + and the main thing it reuses. +- **§2 Interface.** Exactly one fenced block tagged `solidity` holding every new or + changed function, type, error and event with its full signature (visibility, + mutability, returns). Each declaration line ends with `// new` or `// changed`; a + short note may follow the marker, and a brief comment may sit on the line above. + Do not put unchanged declarations in the block. Write the explanation in full + sentences after the block. If the interface does not change, the block contains + only the line `// No interface change.` +- **§3 Changes.** One `###` subsection per behaviour change, titled in plain words + and ending with its ID, for example `### Reject unknown policy types (BEH-1)`. + The first line under the title has this exact shape, with real names and one flag: + + ```text + Functions: `name`, `name`; changes existing behaviour: yes|no + ``` + + Then give the logic as numbered steps in execution order: each check and the error + it raises, the role that authorizes each privileged action, each state change and + each event. If the change is breaking, say which inputs now give a different + outcome and why; keep `breaking` in the front matter consistent. +- **§3 Storage.** One row per field with an `ST-n` ID: slot or derivation, type and + bit range, status (`new`, `changed`, `unchanged`) and whether the Solidity mock and + the Rust precompile use the same layout. Existing slots keep their meaning, type + and position; describe any packing into free bits explicitly. +- **§4 User flows.** One `###` subsection per FLOW-n, in input order, titled in plain + words and ending with its ID, for example `### Issuer combines two policies + (FLOW-1)`. Walk it as numbered steps: setup, the call, state changes and events, + and what the caller observes. A retired flow keeps its subsection and starts with + `retired: not applicable`. +- **§5 What this plan gives up.** The deliberate tradeoffs and what each costs. A + tradeoff never drops a requirement, flow or must-not-break item. +- **§6 Open questions.** Real open questions and their impact on the design, or + `None`. Do not use this section to hide behaviour the plan should specify. + +#### Reference part (§7 to §11) + +- **§7 Requirement trace.** One row per requirement, in your own words. The Source is + either active FLOW-n IDs (`FLOW-1, FLOW-3`) or one complete Motivation sentence + copied verbatim as `Motivation: "..."`. Never mix both forms in one row; never + cite a retired flow. Escape a literal `|` as `\|`. Change IDs are the `BEH-n` and + `ST-n` IDs from §3; every one must exist there. +- **§8 New surface.** Additions only, one row per item counted in the front-matter + `surface`, with Kind one of the six surface keys. Every new function, error and + event is declared `// new` in §2 and has its own row here. Reused or changed items + count zero. A new storage slot cites its new ST row; several fields packed into one + new slot count as one slot. The rows equal the front-matter counts. +- **§9 Hot-path cost.** Each affected path plus `transfer`, `transferFrom`, `mint` and + `burn`, even when unchanged. Worst-case storage operations per call, signed deltas + against the pre-change code, the condition behind each worst case, the slot behind + each packing claim and the target of each external call. +- **§10 Invariants.** One row per CANT-n in input order (retired ones start How it + holds with `retired:`), then `G-INV-1`, then any NEW-n this plan adds. Status is + `holds` or `at risk`; say how each holds and cite the change IDs. For `G-INV-1`, + name every call target or state that there are none. +- **§11 Interactions.** Every mechanism the change touches (policies, roles, pause, + allowances, other precompiles, event consumers) and how; for the others, say why + they do not apply. + +### 4. Self-lint until clean + +The linter runs standalone from this skill's `references/` directory. It needs only +Python 3 and its sibling `plan_inputs.py`; no package install or PYTHONPATH setup. +Its arguments, for reference only, are +`python3 references/lint.py <plan> --input <input>`. + +During a designer pass, run exactly this command from the workspace root and +nothing else: + +```sh +python3 -B .claude/skills/design-plan/references/lint.py .council/plan.md --input .council/input.md +``` + +- Always pass `--input`, so sources and FLOW/CANT coverage are checked. +- Exit 0 is lint-clean. Exit 1 lists problems as `§n RULE: message`: fix the plan only, + with the edit or write tool, then rerun the same command. Repeat until exit 0. +- Exit 2, or a Python error, is not lint-clean. Report the problem; do not edit the + linter and do not claim the plan is clean. + +### 5. Review the content + +The linter checks structure, not design quality. After it passes, check: + +- Every FLOW-n, CANT-n and Motivation sentence is answered by a concrete change. +- Every claim about existing code matches the code you read. +- `G-INV-1` holds consistently in §3 changes, §4 flows, §9 external calls, §10 and §11. +- Surface counts, `breaking` and `activation` agree with §2, §3 and §8. +- Every limit the design adds has a value derived from the flows, with the cost of a + lower and a higher value stated; none is the minimum by default. +- Every new on-chain object states who can change it after creation, compares owner or + admin, creator and nobody against the flows, and for immutability counts the cost of + re-pointing every consumer. +- The readable part reads well on its own: a reviewer who stops after §6 understands + the design, its interface, its behaviour and its costs. + +Then stop. Report the plan path and the final lint result. diff --git a/.claude/skills/design-plan/references/global-invariants.md b/.claude/skills/design-plan/references/global-invariants.md new file mode 100644 index 00000000..eaad818a --- /dev/null +++ b/.claude/skills/design-plan/references/global-invariants.md @@ -0,0 +1,45 @@ +# Global invariant + +One invariant applies to every plan, for every case. Plans cite it as `G-INV-1` in the +Invariants section. Constraints specific to one change are `CANT-*` items in that case's +`input.md`; see [`README.md`](README.md). + +**Designer-visible.** + +## G-INV-1: no calls to non-precompile contracts + +The precompile never calls a non-precompile contract to implement the change. + +- Calls between precompiles are allowed. +- The rule covers calls the precompile makes while carrying out the change. It does not + restrict who may call the precompile. + +### What a judge checks + +A claim that the plan holds the invariant is not enough. The judge compares the claim +with the call path the plan describes, in these sections: + +- Changes: the behavior subsections and every call target they name. +- User flows: each step that reaches another contract. +- Hot-path cost: the external-calls column. +- Invariants: whether the plan states how it holds `G-INV-1`. +- Interactions: every other component the change touches. + +The plan fails the invariant if any of these describes a call from the precompile to a +non-precompile contract, even when the Invariants section says otherwise. + +### Violation + +A plan adds a hook: during the new behavior, the precompile calls an ordinary deployed +contract, such as an admin-supplied checker, and uses its answer to decide whether the +operation succeeds. The decision now depends on code the precompile does not control. + +### Non-example + +A plan has one precompile read state that another precompile owns, or call that +precompile, and use the result. Both sides are precompiles, so the invariant holds. + +## Not listed here + +Repository rules that the rubric already grades are not listed here. This file holds only +the invariant a judge checks separately. diff --git a/.claude/skills/design-plan/references/lint.py b/.claude/skills/design-plan/references/lint.py new file mode 100644 index 00000000..9b54fd40 --- /dev/null +++ b/.claude/skills/design-plan/references/lint.py @@ -0,0 +1,1240 @@ +#!/usr/bin/env python3 +"""Lint a design plan against the plan template v2. + +Usage: python3 template/lint.py PLAN [--input INPUT] [--json] + +The linter checks structure and internal consistency only; it never judges whether a +design is good. Each problem is reported as "§n RULE: message", where §0 means the +front matter or the document as a whole. With --input, the plan is also checked +against the case input: FLOW-n and CANT-n coverage, known IDs, and the FLOW items and +Motivation sentences that the requirement trace cites as sources. + +Exit codes: 0 lint-clean, 1 lint problems, 2 usage or file errors. +Standard library only. +""" + +from __future__ import annotations + +import argparse +import bisect +import json +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path + +# The input parser ships next to this file as plan_inputs.py when the linter is copied out on its own (a skill's +# references); inside this repository it comes from the harness package. +_HERE = Path(__file__).resolve().parent +if (_HERE / "plan_inputs.py").is_file(): + sys.path.insert(0, str(_HERE)) + from plan_inputs import InputDocument, InputError, fence_step, load_input # noqa: E402 +else: + _REPO_ROOT = _HERE.parent + if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + from harness.inputs import InputDocument, InputError, fence_step, load_input # noqa: E402 + +TEMPLATE_VERSION = "v2" +TITLE = f"Design plan (template {TEMPLATE_VERSION})" +# The level-1 heading that separates the readable part from the reference part. +REFERENCE_HEADING = "Reference" + +# Upper bound on body words; a plan longer than this is padding, not precision. +MAX_WORDS = 3500 +# Largest table body allowed in the readable part (the Storage layout table is exempt). +MAX_READABLE_TABLE_ROWS = 6 + +RULES = ( + "L-FM", "L-SEC", "L-COL", "L-TBD", "L-IFACE", "L-BEH", "L-STEPS", "L-TABLE-SIZE", + "L-REQ-SOURCE", "L-COV-FLOW", "L-COV-CANT", "L-COV-GINV", "L-UNKNOWN-ID", "L-CHG-REF", + "L-SURFACE", "L-WORDS", +) + +SECTIONS = ( + "1 Approach", + "2 Interface", + "3 Changes", + "4 User flows", + "5 What this plan gives up", + "6 Open questions", + "7 Requirement trace", + "8 New surface", + "9 Hot-path cost", + "10 Invariants", + "11 Interactions", +) +READABLE_SECTIONS = frozenset(range(1, 7)) +INTERFACE, CHANGES, FLOWS, TRACE, SURFACE, HOT_PATH, INVARIANTS = 2, 3, 4, 7, 8, 9, 10 +STORAGE = "Storage" + +# Table location -> exact header cells. +TABLES: dict[tuple[int, str | None], tuple[str, ...]] = { + (CHANGES, STORAGE): ("ID", "Slot", "Field", "Type", "Status", "Mock = Rust"), + (TRACE, None): ("Requirement", "Source", "Change IDs"), + (SURFACE, None): ("Kind", "Name", "Change IDs"), + (HOT_PATH, None): ( + "Function", "Cold reads", "Warm reads", "Writes", "External calls", "Δ cold", + "Δ warm", "Δ writes", "Packed into a slot already read", + ), + (INVARIANTS, None): ("ID", "Status", "How it holds", "Change IDs"), +} +# Tables that may never be empty, because every plan has rows for them. +NONEMPTY_TABLES = {(TRACE, None), (HOT_PATH, None), (INVARIANTS, None)} + +# Typed cells: (table, column) -> allowed lowercase values. +ENUMS: dict[tuple[tuple[int, str | None], int], tuple[str, ...]] = { + ((CHANGES, STORAGE), 4): ("new", "changed", "unchanged"), + ((INVARIANTS, None), 1): ("holds", "at risk"), +} + +SURFACE_KINDS = ("external_functions", "roles", "storage_slots", "config_params", "errors", "events") +# Surface kinds that must be declared in the §2 interface block, and the Solidity keyword that declares them. +DECLARED_KINDS = {"external_functions": "function", "errors": "error", "events": "event"} +FM_SCALARS = ("plan_id", "perspective", "inputs_ref", "activation") +FM_KEYS = (*FM_SCALARS, "breaking", "surface") + +CHANGE_ID_RE = re.compile(r"^(ST|BEH)-([1-9]\d*)$") +CHANGE_SHAPE_RE = re.compile(r"^(ST|BEH)-\d+$") +FLOW_RE = re.compile(r"^FLOW-([1-9]\d*)$") +CANT_RE = re.compile(r"^CANT-([1-9]\d*)$") +NEW_RE = re.compile(r"^NEW-([1-9]\d*)$") +BEH_RE = re.compile(r"^BEH-([1-9]\d*)$") +GINV = "G-INV-1" +# Cells that state "nothing here" in reference columns. +EMPTY_SENTINELS = {"none", "—", "-", "n/a"} + +# A subsection heading that carries its trace ID at the end: "Readable title (BEH-1)". +_TAGGED_HEADING_RE = re.compile(r"^(?P<title>\S.*?)[ \t]+\((?P<id>[A-Z][A-Z-]*-\d+)\)$") +# The first line of a behaviour change: the functions it touches and whether existing behaviour changes. +FUNCTIONS_RE = re.compile( + r"^Functions?:[ \t]*(?P<names>\S.*);[ \t]*changes existing behaviour:[ \t]*(?P<flag>yes|no)\.?$", + re.IGNORECASE, +) +FUNCTIONS_SHAPE = "Functions: `name`, `name`; changes existing behaviour: yes|no" +_STEP_RE = re.compile(r"^ {0,3}\d{1,9}[.)][ \t]+\S") +_DECLARATION_RE = re.compile(r"\b(function|error|event|struct|enum|type|modifier)[ \t]+([A-Za-z_][A-Za-z0-9_]*)") +_STATUS_RE = re.compile(r"\s*(new|changed)\b") +NO_INTERFACE_SENTINEL = "// No interface change." +_BLOCK_COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL) +_IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") + +_HEADING_RE = re.compile(r"^ {0,3}(#{1,6})[ \t]+(.*?)(?:[ \t]+#+)?[ \t]*$") +_SEPARATOR_CELL_RE = re.compile(r"^:?-+:?$") +_TBD_RE = re.compile(r"\bTBD\b", re.IGNORECASE) +# A template placeholder: <text> not glued to a preceding word (so Vec<u8> is fine) and +# not padded with spaces (so "a < b and c > d" is fine). It may wrap across lines of one +# paragraph, but never across a blank line. +_PLACEHOLDER_RE = re.compile( + r"(?<![\w<])<([^\s<>=]|[^\s<>=](?:[^<>\n]|\n(?![ \t]*(?:\n|$)))*[^\s<>=])>" +) +_QUOTES = (('"', '"'), ("“", "”")) +MOTIVATION_PREFIX = "Motivation:" +# Shortest normalized Motivation quote accepted as a requirement source. +MIN_QUOTE_CHARS = 20 +SOURCE_SHAPE = 'requirement source must be FLOW-n references or Motivation: "<sentence>"' +_LIST_MARKER_RE = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s+") +# Characters that may close a sentence after its terminator: quotes, brackets, emphasis. +_SETEXT_RE = re.compile(r"^ {0,3}(?:=+|-+)[ \t]*$") +_TABLE_DELIMITER_RE = re.compile(r"^\s*\|?\s*:?-+:?\s*(?:\|\s*:?-+:?\s*)*\|?\s*$") +# Lines that end a table body: a heading, block quote, fence, HTML block, list item or thematic break. +_BLOCK_START_RE = re.compile(r"^ {0,3}(?:#{1,6}(?:[ \t]|$)|>|```|~~~|<|[-*+][ \t]|\d{1,9}[.)][ \t]|(?:[-*_][ \t]*){3,}$)") +_SENTENCE_CLOSERS = "\"'”’)]*_" + + +@dataclass(frozen=True) +class Diagnostic: + section: int + rule: str + message: str + line: int | None = None + + def text(self) -> str: + return f"§{self.section} {self.rule}: {self.message}" + + def as_dict(self) -> dict: + return {"section": self.section, "rule": self.rule, "message": self.message, "line": self.line} + + +@dataclass +class Table: + key: tuple[int, str | None] + line: int + header: list[str] + rows: list[tuple[int, list[str]]] = field(default_factory=list) + + +@dataclass +class Region: + """Lines under one heading, up to the next ## heading or the next subsection heading. + + `sub` is None for a section body, "Storage" for the §3 storage subsection, the trace ID + for a tagged subsection (BEH-n in §3, FLOW-n in §4), or "?" for any other subsection there. + """ + + section: int + sub: str | None + lines: list[tuple[int, str]] = field(default_factory=list) + heading_line: int | None = None + title: str | None = None + + +@dataclass(frozen=True) +class Change: + """One §3 behaviour change: its heading, the functions it names and whether it changes existing behaviour.""" + + id: str + title: str + line: int + functions: str | None # raw names after "Functions:", or None when the line is missing or malformed + changes_existing: bool | None + + +def function_names(raw: str) -> list[str]: + """Names in a Functions line: comma-separated, parenthesized remarks dropped, code marks stripped.""" + label = raw + while re.search(r"\([^()]*\)", label): + label = re.sub(r"\([^()]*\)", " ", label) + return [part.strip().strip("`*").strip() for part in label.split(",") if part.strip()] + + +class _Linter: + def __init__(self, text: str, document: InputDocument | None) -> None: + self.text = text + self.doc = document + self.diags: list[Diagnostic] = [] + self.lines = text.splitlines() + self.body_start = 0 # index of the first body line + self.front: dict | None = None + self.tables: dict[tuple[int, str | None], Table] = {} + self.section_lines: dict[int, int] = {} + self.changes: dict[str, Change] = {} + # Names declared in the §2 Solidity block by keyword, or None when there is no single block. + self.interface: dict[str, set[str]] | None = None + # The subset of those names marked `// new`, by keyword. + self.new_interface: dict[str, set[str]] = {} + + def add(self, section: int, rule: str, message: str, line: int | None = None) -> None: + self.diags.append(Diagnostic(section, rule, message, line)) + + # ---- parsing ------------------------------------------------------------------- + + def parse_front_matter(self) -> None: + lines = self.lines + if not lines or lines[0].rstrip() != "---": + self.add(0, "L-FM", "plan does not start with a '---' front matter block", 1) + return + end = next((i for i in range(1, len(lines)) if lines[i].rstrip() == "---"), None) + if end is None: + self.add(0, "L-FM", "front matter is not closed with '---'", 1) + return + self.body_start = end + 1 + values: dict = {} + surface: dict[str, int] | None = None + surface_open = False # indented count lines belong to the surface key just above + attempted: set[str] = set() # keys present, valid or not, so missing reports stay precise + ok = True + + def fail(message: str, index: int) -> None: + nonlocal ok + ok = False + self.add(0, "L-FM", message, index + 1) + + for index in range(1, end): + raw = lines[index] + if not raw.strip(): + continue + if "\t" in raw: + fail("tabs are not allowed in front matter", index) + continue + nested = raw.startswith(" ") + match = re.match(r"^( *)([A-Za-z_][A-Za-z0-9_]*):(?:[ ]+(.*?))?[ ]*$", raw) + if not match: + fail(f"unsupported front matter line: {raw.strip()!r}", index) + continue + indent, key, value = len(match.group(1)), match.group(2), match.group(3) + attempted.add(("surface." if nested else "") + key) + if nested: + if not surface_open or surface is None or indent != 2: + fail(f"unexpected indentation for '{key}'", index) + continue + if key not in SURFACE_KINDS: + fail(f"unknown surface key '{key}'", index) + continue + if key in surface: + fail(f"duplicate surface key '{key}'", index) + continue + if value is None or not re.fullmatch(r"\d+", value): + fail(f"surface.{key} must be a nonnegative integer", index) + continue + surface[key] = int(value) + continue + surface_open = False + if key not in FM_KEYS: + fail(f"unknown front matter key '{key}'", index) + continue + if key in values: + fail(f"duplicate front matter key '{key}'", index) + continue + if key == "surface": + if value: + fail("surface must be a mapping of counts on indented lines", index) + continue + surface = {} + surface_open = True + values[key] = surface + continue + if value is None or value == "": + fail(f"'{key}' has no value", index) + continue + if key == "breaking": + if value not in ("true", "false"): + fail("breaking must be true or false", index) + continue + values[key] = value == "true" + continue + parsed = _scalar(value) + if parsed is None: + fail(f"'{key}' uses unsupported YAML syntax", index) + continue + if not parsed.strip(): + fail(f"'{key}' is empty", index) + continue + values[key] = parsed + for key in FM_KEYS: + if key not in values: + ok = False + if key in attempted: + continue + self.add(0, "L-FM", f"missing front matter key '{key}'", 1) + if isinstance(values.get("surface"), dict): + for kind in SURFACE_KINDS: + if kind not in values["surface"]: + ok = False + if "surface." + kind in attempted: + continue + self.add(0, "L-FM", f"missing surface count '{kind}'", 1) + if ok: + self.front = values + + def visible(self) -> tuple[list[tuple[int, str]], list[tuple[int, str]]]: + """Body lines with HTML comments removed: (structural lines, all visible lines). + + Structural lines also drop fenced code, so headings and tables in examples do not + count. All visible lines keep fenced content for placeholder, word and interface checks. + Comments and fences are tracked in source order: a fence inside a comment opens no + code block, and comment markers inside fenced code are literal text. + """ + body = self.lines[self.body_start:] + structural: list[tuple[int, str]] = [] + visible: list[tuple[int, str]] = [] + in_comment = False + fence: tuple[str, int] | None = None + for offset, line in enumerate(body): + number = self.body_start + offset + 1 + if not in_comment: + role, fence = fence_step(fence, line) + if role is not None: + visible.append((number, line)) + continue + kept, in_comment = _strip_comments(line, in_comment) + visible.append((number, kept)) + structural.append((number, kept)) + return structural, visible + + def split_regions(self, structural: list[tuple[int, str]]) -> list[Region]: + headings: list[tuple[int, int, str]] = [] + for number, line in structural: + match = _HEADING_RE.match(line) + if match: + headings.append((number, len(match.group(1)), match.group(2).strip())) + self.check_sections(headings) + + regions: list[Region] = [] + current: Region | None = None + heading_at = {number: (level, title) for number, level, title in headings} + seen_sections: set[int] = set() + seen_subs: set[tuple[int, str]] = set() + for number, line in structural: + if number in heading_at: + level, title = heading_at[number] + if level == 2: + current = None + if title in SECTIONS: + index = SECTIONS.index(title) + 1 + if index not in seen_sections: + seen_sections.add(index) + self.section_lines[index] = number + current = Region(index, None, heading_line=number, title=title) + regions.append(current) + continue + if level == 3 and current is not None and current.section in (CHANGES, FLOWS): + section = current.section + sub = _subsection_key(section, title) + if sub != "?" and (section, sub) in seen_subs: + sub = "?" + seen_subs.add((section, sub)) + tagged = _TAGGED_HEADING_RE.match(title) + current = Region(section, sub, heading_line=number, + title=tagged.group("title") if tagged else title) + regions.append(current) + continue + if level == 1: + current = None + continue + if level == 3: + continue # explanatory subheadings elsewhere stay in their section + if current is not None: + current.lines.append((number, line)) + return regions + + def check_sections(self, headings: list[tuple[int, int, str]]) -> None: + opening = headings[0] if headings else None + if opening is None or opening[1] != 1 or opening[2] != TITLE: + self.add(0, "L-SEC", f"the plan must open with the heading '# {TITLE}'", opening[0] if opening else None) + level1 = [(n, t) for n, lvl, t in headings if lvl == 1] + for number, title in level1: + if title != REFERENCE_HEADING and not (opening is not None and number == opening[0]): + self.add(0, "L-SEC", f"unexpected heading '# {title}'", number) + level2 = [(n, t) for n, lvl, t in headings if lvl == 2] + titles = [t for _, t in level2] + for number, title in level2: + if title not in SECTIONS: + self.add(0, "L-SEC", f"unexpected section heading '## {title}'", number) + for index, title in enumerate(SECTIONS, start=1): + count = titles.count(title) + if count == 0: + self.add(index, "L-SEC", f"missing section '## {title}'") + elif count > 1: + self.add(index, "L-SEC", f"section '## {title}' appears {count} times") + known = [t for t in titles if t in SECTIONS] + deduped = list(dict.fromkeys(known)) + if deduped != [t for t in SECTIONS if t in deduped]: + self.add(0, "L-SEC", "sections are out of order") + + # The reference part starts with '# Reference', between the last readable and the first reference section. + references = [n for n, t in level1 if t == REFERENCE_HEADING] + if len(references) != 1: + self.add(0, "L-SEC", f"expected one '# {REFERENCE_HEADING}' heading before '## {SECTIONS[TRACE - 1]}', " + f"found {len(references)}") + else: + first = {t: n for n, t in reversed(level2)} + before, after = first.get(SECTIONS[TRACE - 2]), first.get(SECTIONS[TRACE - 1]) + if (before is not None and references[0] < before) or (after is not None and references[0] > after): + self.add(0, "L-SEC", f"'# {REFERENCE_HEADING}' must sit between '## {SECTIONS[TRACE - 2]}' and " + f"'## {SECTIONS[TRACE - 1]}'", references[0]) + + subs: dict[int, list[tuple[int, str]]] = {CHANGES: [], FLOWS: []} + section = None + for number, level, title in headings: + if level <= 2: + section = SECTIONS.index(title) + 1 if level == 2 and title in SECTIONS else None + continue + if level == 3 and section in subs: + subs[section].append((number, title)) + seen_flows: set[str] = set() + for number, title in subs[FLOWS]: + key = _subsection_key(FLOWS, title) + if key == "?": + self.add(FLOWS, "L-SEC", f"unexpected subsection '### {title}' (expected 'Title (FLOW-n)')", number) + elif key in seen_flows: + self.add(FLOWS, "L-COV-FLOW", f"duplicate subsection for {key}", number) + seen_flows.add(key) + if SECTIONS[CHANGES - 1] not in titles: + return + keys = [(_subsection_key(CHANGES, t), n, t) for n, t in subs[CHANGES]] + seen_changes: set[str] = set() + for key, number, title in keys: + if key == "?": + self.add(CHANGES, "L-SEC", f"unexpected subsection '### {title}' (expected 'Title (BEH-n)' or " + f"'{STORAGE}')", number) + elif key != STORAGE and key in seen_changes: + self.add(CHANGES, "L-CHG-REF", f"duplicate change ID {key}", number) + seen_changes.add(key) + storage = [n for key, n, _ in keys if key == STORAGE] + if not storage: + self.add(CHANGES, "L-SEC", f"missing subsection '### {STORAGE}'") + elif len(storage) > 1: + self.add(CHANGES, "L-SEC", f"subsection '### {STORAGE}' appears {len(storage)} times") + elif keys[-1][0] != STORAGE: + self.add(CHANGES, "L-SEC", f"'### {STORAGE}' must be the last subsection of §3", storage[0]) + if not any(BEH_RE.match(key) for key, _, _ in keys): + self.add(CHANGES, "L-SEC", "§3 has no behaviour change subsection 'Title (BEH-n)'") + + def parse_tables(self, regions: list[Region]) -> None: + for region in regions: + key = (region.section, region.sub) + if key not in TABLES: + continue + blocks = _table_blocks(region.lines) + section = region.section + if not blocks: + self.add(section, "L-COL", f"{_where(key)} has no table") + continue + if len(blocks) > 1: + self.add(section, "L-COL", f"{_where(key)} has more than one table", blocks[1][0][0]) + block = blocks[0] + expected = TABLES[key] + header_line, header_raw = block[0] + header = [_decode(c) for c in _split_row(header_raw)] + if tuple(header) != expected: + self.add(section, "L-COL", f"{_where(key)} header is {header!r}, expected {list(expected)!r}", header_line) + continue + if len(block) < 2 or not _is_separator(_split_row(block[1][1]), len(expected)): + self.add(section, "L-COL", f"{_where(key)} table has no valid separator row", header_line) + continue + table = Table(key, header_line, header) + for number, raw in block[2:]: + cells = _split_row(raw) + if len(cells) != len(expected): + self.add(section, "L-COL", f"{_where(key)} row has {len(cells)} cells, expected {len(expected)}", number) + continue + table.rows.append((number, cells)) + if not table.rows and len(block) == 2: + after = [line for number, line in region.lines if number > block[-1][0] and line.strip()] + if key in NONEMPTY_TABLES: + self.add(section, "L-COL", f"{_where(key)} table has no rows", header_line) + elif not after or not after[0].strip().startswith("None:"): + self.add(section, "L-COL", f"{_where(key)} table is empty without a 'None:' reason", header_line) + for (table_key, column), allowed in ENUMS.items(): + if table_key != key: + continue + for number, cells in table.rows: + value = _plain(cells[column]).lower() + if key == (INVARIANTS, None) and _is_retired_row(cells[2]) and value == "retired": + continue + if value not in allowed: + self.add(section, "L-COL", f"{_where(key)} '{expected[column]}' is '{_plain(cells[column])}', expected one of {', '.join(allowed)}", number) + self.tables[key] = table + + # ---- rules ---------------------------------------------------------------------- + + def check_placeholders(self, visible: list[tuple[int, str]]) -> None: + for index in range(0, self.body_start): + self._scan_placeholders(0, index + 1, self.lines[index]) + + for number, line in visible: + if _TBD_RE.search(line): + self.add(self.section_of(number), "L-TBD", f"line {number}: contains TBD", number) + # Placeholders may wrap, so scan the visible body as one text and report the opening line. + offsets: list[int] = [] + position = 0 + for _, line in visible: + offsets.append(position) + position += len(line) + 1 + text = "\n".join(line for _, line in visible) + for match in _PLACEHOLDER_RE.finditer(text): + number = visible[bisect.bisect_right(offsets, match.start()) - 1][0] + shown = _short(_collapse(match.group(0))) + self.add(self.section_of(number), "L-TBD", f"line {number}: unfilled placeholder '{shown}'", number) + + def section_of(self, number: int) -> int: + section = 0 + for sec, start in sorted(self.section_lines.items(), key=lambda item: item[1]): + if number >= start: + section = sec + return section + + def _scan_placeholders(self, section: int, number: int, line: str) -> None: + if _TBD_RE.search(line): + self.add(section, "L-TBD", f"line {number}: contains TBD", number) + for match in _PLACEHOLDER_RE.finditer(line): + self.add(section, "L-TBD", f"line {number}: unfilled placeholder '{match.group(0)}'", number) + + def check_words(self, visible: list[tuple[int, str]]) -> None: + count = sum( + 1 for _, line in visible for token in line.split() if any(ch.isalnum() for ch in token) + ) + if count > MAX_WORDS: + self.add(0, "L-WORDS", f"plan body has {count} words, maximum is {MAX_WORDS}") + + def check_interface(self, visible: list[tuple[int, str]]) -> None: + """§2 holds exactly one nonempty ```solidity block; record the names it declares.""" + start = self.section_lines.get(INTERFACE) + if start is None: + return + end = min((n for n in self.section_lines.values() if n > start), default=len(self.lines) + 1) + blocks: list[tuple[int, list[str]]] = [] + current: list[str] | None = None + fence: tuple[str, int] | None = None + for number, line in visible: + if not start < number < end: + continue + role, fence = fence_step(fence, line) + if role == "delim": + if fence is not None: # an opening delimiter + info = line.strip().lstrip(line.strip()[0]).strip().split() + current = [] if info and info[0].lower() == "solidity" else None + if current is not None: + blocks.append((number, current)) + else: + current = None + elif role == "inside" and current is not None: + current.append(line) + if not blocks: + self.add(INTERFACE, "L-IFACE", "§2 has no ```solidity block; write the interface as Solidity", start) + return + if len(blocks) > 1: + self.add(INTERFACE, "L-IFACE", f"§2 has {len(blocks)} ```solidity blocks; put the whole interface in one", + blocks[1][0]) + return + number, body = blocks[0] + if not any(line.strip() for line in body): + self.add(INTERFACE, "L-IFACE", "the §2 solidity block is empty", number) + return + # Blank out block comments but keep line breaks, so every declaration keeps its own line. + code = _BLOCK_COMMENT_RE.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), "\n".join(body)) + declared: dict[str, set[str]] = {} + new: dict[str, set[str]] = {} + sentinel = False + for offset, line in enumerate(code.splitlines(), start=number + 1): + source, _, comment = line.partition("//") + if line.strip() == NO_INTERFACE_SENTINEL: + sentinel = True + continue + found = _DECLARATION_RE.findall(source) + if not found: + continue + status = _STATUS_RE.match(comment.lstrip("/")) + for keyword, name in found: + declared.setdefault(keyword, set()).add(name) + if status is None: + self.add(INTERFACE, "L-IFACE", f"{keyword} '{name}' has no trailing '// new' or '// changed' " + "comment on its declaration line", offset) + elif status.group(1) == "new": + new.setdefault(keyword, set()).add(name) + if sentinel and declared: + self.add(INTERFACE, "L-IFACE", f"'{NO_INTERFACE_SENTINEL}' cannot sit beside declarations", number) + elif sentinel and any(line.strip() and line.strip() != NO_INTERFACE_SENTINEL for line in body): + self.add(INTERFACE, "L-IFACE", f"a block with '{NO_INTERFACE_SENTINEL}' holds nothing else; put the " + "explanation in prose after the block", number) + elif not sentinel and not declared: + self.add(INTERFACE, "L-IFACE", "the §2 solidity block declares nothing; declare the new or changed " + f"interface, or write only '{NO_INTERFACE_SENTINEL}'", number) + self.interface = declared + self.new_interface = new + + def check_readable_tables(self, regions: list[Region]) -> None: + """Tables in the readable part stay small; the Storage layout table is exempt.""" + for region in regions: + if region.section not in READABLE_SECTIONS or (region.section, region.sub) == (CHANGES, STORAGE): + continue + for block in _table_blocks(region.lines): + rows = len(block) - 2 if len(block) >= 2 and _is_table_start(block[0][1], block[1][1]) else len(block) - 1 + if rows > MAX_READABLE_TABLE_ROWS: + self.add(region.section, "L-TABLE-SIZE", + f"table has {rows} rows; the readable part allows at most {MAX_READABLE_TABLE_ROWS}, " + "so use code, steps or a diagram", block[0][0]) + + def collect_changes(self, regions: list[Region]) -> dict[str, tuple[str, object]]: + """Return change ID -> (kind, detail) for every §3 behaviour subsection and Storage row.""" + declared: dict[str, tuple[str, object]] = {} + for region in regions: + if region.section != CHANGES or region.sub is None or not BEH_RE.match(region.sub): + continue + declared[region.sub] = ("Behavior", region) + self.changes[region.sub] = self._read_change(region) + table = self.tables.get((CHANGES, STORAGE)) + for number, cells in table.rows if table is not None else []: + change_id = _plain(cells[0]) + match = CHANGE_ID_RE.match(change_id) + if not match or match.group(1) != "ST": + self.add(CHANGES, "L-CHG-REF", f"'{change_id}' is not a valid ID for ### {STORAGE} (expected ST-n)", number) + continue + if change_id in declared: + self.add(CHANGES, "L-CHG-REF", f"duplicate change ID {change_id}", number) + continue + declared[change_id] = ("Storage", cells) + return declared + + def _read_change(self, region: Region) -> Change: + lines = [(n, line.strip()) for n, line in region.lines if line.strip()] + functions, changes_existing = None, None + first = lines[0] if lines else (region.heading_line, "") + match = FUNCTIONS_RE.match(first[1]) + if match: + functions, changes_existing = match.group("names").strip(), match.group("flag").lower() == "yes" + if not function_names(functions): + functions = None + if functions is None: + self.add(CHANGES, "L-BEH", f"{region.sub} must start with the line '{FUNCTIONS_SHAPE}'", first[0]) + if not any(_STEP_RE.match(line) for _, line in region.lines): + self.add(CHANGES, "L-STEPS", f"{region.sub} has no numbered steps", region.heading_line) + return Change(region.sub, region.title or "", region.heading_line or 0, functions, changes_existing) + + def check_change_cell(self, section: int, number: int, cell: str, declared: dict, required: bool, what: str) -> list[str]: + tokens = _tokens(cell) + if tokens is None: + if required: + self.add(section, "L-CHG-REF", f"{what} cites no change IDs", number) + return [] + good: list[str] = [] + for token in tokens: + if not CHANGE_SHAPE_RE.match(token): + self.add(section, "L-UNKNOWN-ID", f"{what} cites '{token}', which is not a change ID (BEH-n or ST-n)", number) + elif not CHANGE_ID_RE.match(token): + self.add(section, "L-UNKNOWN-ID", f"{what} cites non-canonical ID '{token}'", number) + elif token not in declared: + self.add(section, "L-CHG-REF", f"{what} cites {token}, which is not defined in §3", number) + else: + good.append(token) + return good + + def run(self) -> list[Diagnostic]: + self.parse_front_matter() + structural, visible = self.visible() + regions = self.split_regions(structural) + self.parse_tables(regions) + self.check_placeholders(visible) + self.check_words(visible) + self.check_interface(visible) + self.check_readable_tables(regions) + + declared = self.collect_changes(regions) + flow_ids, cant_ids, retired = self.source_ids() + self.check_invariants(cant_ids, retired, declared) + self.check_flows(regions, flow_ids, retired) + self.check_trace(declared, regions, flow_ids, retired) + self.check_surface(declared) + return self.diags + + def source_ids(self) -> tuple[list[str] | None, list[str] | None, set[str]]: + """FLOW and CANT IDs from the input, or None without input. Also retired IDs.""" + if self.doc is None: + return None, None, set() + retired = {item.id for item in (*self.doc.flows, *self.doc.cants) if _is_retired_item(item.text)} + return [i.id for i in self.doc.flows], [i.id for i in self.doc.cants], retired + + def flow_regions(self, regions: list[Region]) -> list[Region]: + return [r for r in regions if r.section == FLOWS and r.sub is not None and FLOW_RE.match(r.sub)] + + def check_flows(self, regions: list[Region], flow_ids: list[str] | None, retired: set[str]) -> None: + if FLOWS not in self.section_lines: + return + seen: list[str] = [] + for region in self.flow_regions(regions): + row_id, number = region.sub, region.heading_line + seen.append(row_id) + first = next((line for _, line in region.lines if line.strip()), "") + marked = _is_retired_row(first) + if flow_ids is not None: + if row_id not in flow_ids: + self.add(FLOWS, "L-UNKNOWN-ID", f"{row_id} is not in the input", number) + continue + if marked and row_id not in retired: + self.add(FLOWS, "L-COV-FLOW", f"{row_id} is active in the input but marked retired", number) + if row_id in retired and not marked: + self.add(FLOWS, "L-COV-FLOW", f"{row_id} is retired in the input; start its subsection with " + "'retired: ...'", number) + is_retired = marked if flow_ids is None else row_id in retired + if not is_retired and not any(_STEP_RE.match(line) for _, line in region.lines): + self.add(FLOWS, "L-STEPS", f"{row_id} has no numbered steps", number) + if flow_ids is not None: + missing = [i for i in flow_ids if i not in seen] + if missing: + self.add(FLOWS, "L-COV-FLOW", f"missing subsections for {', '.join(missing)}") + present = [i for i in seen if i in flow_ids] + if not missing and present != flow_ids: + self.add(FLOWS, "L-COV-FLOW", "FLOW subsections are not in input order") + elif not seen: + self.add(FLOWS, "L-COV-FLOW", "§4 has no 'Title (FLOW-n)' subsections") + elif seen != sorted(seen, key=lambda s: int(s.split("-")[1])): + self.add(FLOWS, "L-COV-FLOW", "FLOW subsections are not in order") + + def check_invariants(self, cant_ids: list[str] | None, retired: set[str], declared: dict) -> set[str]: + table = self.tables.get((INVARIANTS, None)) + if table is None: + return set() + section = INVARIANTS + cants: list[str] = [] + news: list[str] = [] + ginv_rows = 0 + phase = 0 # 0 CANT rows, 1 after G-INV-1 + for number, cells in table.rows: + row_id = _plain(cells[0]) + marked = _is_retired_row(cells[2]) + if CANT_RE.match(row_id): + if phase: + self.add(section, "L-COV-CANT", f"{row_id} comes after {GINV}; CANT rows come first", number) + if row_id in cants: + self.add(section, "L-COV-CANT", f"duplicate row for {row_id}", number) + continue + cants.append(row_id) + if cant_ids is not None: + if row_id not in cant_ids: + self.add(section, "L-UNKNOWN-ID", f"{row_id} is not in the input", number) + continue + if marked and row_id not in retired: + self.add(section, "L-COV-CANT", f"{row_id} is active in the input but marked retired", number) + if row_id in retired and not marked: + self.add(section, "L-COV-CANT", f"{row_id} is retired in the input; write 'retired: ...' in How it holds", number) + elif row_id == GINV: + ginv_rows += 1 + if ginv_rows > 1: + self.add(section, "L-COV-GINV", f"duplicate row for {GINV}", number) + phase = 1 + if marked: + self.add(section, "L-COV-GINV", f"{GINV} cannot be retired", number) + elif NEW_RE.match(row_id): + if not phase: + self.add(section, "L-COV-GINV", f"{row_id} comes before {GINV}; NEW rows come last", number) + if row_id in news: + self.add(section, "L-UNKNOWN-ID", f"duplicate invariant ID {row_id}", number) + continue + news.append(row_id) + else: + self.add(section, "L-UNKNOWN-ID", f"'{row_id}' is not a CANT-n, {GINV} or NEW-n ID", number) + continue + # Invariants may hold without any change touching them, so change IDs are optional. + self.check_change_cell(section, number, cells[3], declared, False, row_id) + if ginv_rows == 0: + self.add(section, "L-COV-GINV", f"missing row for {GINV}") + if cant_ids is not None: + missing = [i for i in cant_ids if i not in cants] + if missing: + self.add(section, "L-COV-CANT", f"missing rows for {', '.join(missing)}") + present = [i for i in cants if i in cant_ids] + if not missing and present != cant_ids: + self.add(section, "L-COV-CANT", "CANT rows are not in input order") + elif cants != sorted(cants, key=lambda s: int(s.split("-")[1])): + self.add(section, "L-COV-CANT", "CANT rows are not in order") + return set(news) + + def check_trace(self, declared: dict, regions: list[Region], flow_ids: list[str] | None, retired: set[str]) -> None: + table = self.tables.get((TRACE, None)) + if table is None: + return + flows, retired_flows, where = self.citable_flows(regions, flow_ids, retired) + sentences = _motivation_sentences(self.doc.motivation) if self.doc is not None else None + for number, cells in table.rows: + statement = _decode(cells[0]).strip() + if not statement or statement.lower() in EMPTY_SENTINELS: + self.add(TRACE, "L-REQ-SOURCE", "requirement statement is empty", number) + problem = _source_problem(cells[1], flows, retired_flows, where, sentences) + if problem is not None: + self.add(TRACE, "L-REQ-SOURCE", problem, number) + self.check_change_cell(TRACE, number, cells[2], declared, True, "requirement") + + def citable_flows( + self, regions: list[Region], flow_ids: list[str] | None, retired: set[str] + ) -> tuple[set[str] | None, set[str], str]: + """FLOW IDs a requirement may cite, retired ones, and where they come from. + + The input is authoritative when given; otherwise the §4 subsections stand in for it. + Without either, FLOW references are checked for form only. + """ + if flow_ids is not None: + return set(flow_ids), retired & set(flow_ids), "the input" + if FLOWS not in self.section_lines: + return None, set(), "§4" + flows: set[str] = set() + marked: set[str] = set() + for region in self.flow_regions(regions): + flows.add(region.sub) + first = next((line for _, line in region.lines if line.strip()), "") + if _is_retired_row(first): + marked.add(region.sub) + return flows, marked, "§4" + + def check_surface(self, declared: dict) -> None: + table = self.tables.get((SURFACE, None)) + if table is None: + return + counts = {kind: 0 for kind in SURFACE_KINDS} + seen: set[tuple[str, str]] = set() + listed: dict[str, set[str]] = {} + for number, cells in table.rows: + kind, name = _plain(cells[0]), _plain(cells[1]) + if kind not in SURFACE_KINDS: + self.add(SURFACE, "L-SURFACE", f"unknown kind '{kind}' (expected one of {', '.join(SURFACE_KINDS)})", number) + continue + if not name: + self.add(SURFACE, "L-SURFACE", f"{kind} row has no name", number) + continue + if (kind, name) in seen: + self.add(SURFACE, "L-SURFACE", f"{kind} '{name}' is listed twice", number) + continue + seen.add((kind, name)) + counts[kind] += 1 + keyword = DECLARED_KINDS.get(kind) + # Functions, errors and events are anchored by their §2 declaration, so citing a change is optional. + refs = self.check_change_cell(SURFACE, number, cells[2], declared, keyword is None, f"{kind} '{name}'") + if keyword is not None and self.interface is not None: + identifier = _IDENTIFIER_RE.search(name) + if identifier is None or identifier.group(0) not in self.new_interface.get(keyword, set()): + self.add(SURFACE, "L-SURFACE", f"{kind} '{name}' is not declared as a '// new' {keyword} in the " + "§2 solidity block", number) + else: + listed.setdefault(kind, set()).add(identifier.group(0)) + if kind == "storage_slots" and refs and not any( + r.startswith("ST-") and _plain(declared[r][1][4]).lower() == "new" for r in refs + ): + self.add(SURFACE, "L-SURFACE", f"{kind} '{name}' does not cite a new ST row", number) + # Every `// new` function, error and event is new surface and needs its own row. + if self.interface is not None: + for kind, keyword in DECLARED_KINDS.items(): + for name in sorted(self.new_interface.get(keyword, set()) - listed.get(kind, set())): + self.add(SURFACE, "L-SURFACE", f"{keyword} '{name}' is declared '// new' in §2 but no {kind} row " + "lists it", table.line) + if self.front is not None: + for kind in SURFACE_KINDS: + claimed = self.front["surface"][kind] + if claimed != counts[kind]: + self.add(SURFACE, "L-SURFACE", f"front matter surface.{kind} is {claimed} but §8 New surface lists {counts[kind]}") + + +def _subsection_key(section: int, title: str) -> str: + """Region key for a ### heading in §3 or §4: "Storage", the trace ID it is tagged with, or "?".""" + if section == CHANGES and title == STORAGE: + return STORAGE + tagged = _TAGGED_HEADING_RE.match(title) + if tagged is None: + return "?" + pattern = BEH_RE if section == CHANGES else FLOW_RE + return tagged.group("id") if pattern.match(tagged.group("id")) else "?" + + +# ---- helpers ---------------------------------------------------------------------------- + + +def _scalar(value: str) -> str | None: + """Parse a restricted YAML scalar: quoted, or plain text without YAML indicators.""" + if value[0] in "\"'": + quote = value[0] + if len(value) < 2 or value[-1] != quote: + return None + inner = value[1:-1] + if quote == "'": + if re.search(r"(?<!')'(?!')", inner.replace("''", "")): + return None + return inner.replace("''", "'") + if re.search(r'(?<!\\)"', inner): + return None + return inner.replace('\\"', '"').replace("\\\\", "\\") + if value[0] in "[]{}&*!|>%@`,#" or " #" in value or value.startswith("- "): + return None + # A colon followed by a space (or ending the value) starts a mapping, which a plain scalar cannot hold. + if ": " in value or value.endswith(":"): + return None + return value + + +def _strip_comments(line: str, in_comment: bool) -> tuple[str, bool]: + out = [] + i = 0 + while i < len(line): + if in_comment: + end = line.find("-->", i) + if end < 0: + return "".join(out), True + i = end + 3 + in_comment = False + else: + start = line.find("<!--", i) + if start < 0: + out.append(line[i:]) + break + out.append(line[i:start]) + i = start + 4 + in_comment = True + return "".join(out), in_comment + + +def _table_blocks(lines: list[tuple[int, str]]) -> list[list[tuple[int, str]]]: + """Group table lines into blocks: (line number, raw line) per row, header and delimiter included. + + A GitHub-flavoured table is a row with an unescaped pipe directly followed by a delimiter row with the same + number of cells; outer pipes are optional. It runs to the next blank line or the start of another block. A run + of pipe-led lines with no delimiter row is also returned, so a malformed table is reported rather than skipped. + """ + blocks: list[list[tuple[int, str]]] = [] + i = 0 + while i < len(lines): + number, line = lines[i] + if i + 1 < len(lines) and lines[i + 1][0] == number + 1 and _is_table_start(line, lines[i + 1][1]): + block = [lines[i], lines[i + 1]] + i += 2 + while (i < len(lines) and lines[i][0] == block[-1][0] + 1 and lines[i][1].strip() + and not _BLOCK_START_RE.match(lines[i][1])): + block.append(lines[i]) + i += 1 + blocks.append(block) + continue + if line.strip().startswith("|"): + block = [] + while i < len(lines) and lines[i][1].strip().startswith("|") and ( + not block or lines[i][0] == block[-1][0] + 1): + block.append(lines[i]) + i += 1 + blocks.append(block) + continue + i += 1 + return blocks + + +def _has_pipe(line: str) -> bool: + """True when the line has a pipe that no backslash escapes.""" + return len(_split_row("x" + line.strip() + "x")) > 1 + + +def _is_table_start(header: str, delimiter: str) -> bool: + if not _has_pipe(header) or not _has_pipe(delimiter) or not _TABLE_DELIMITER_RE.match(delimiter): + return False + return len(_split_row(header)) == len(_split_row(delimiter)) + + +def _split_row(raw: str) -> list[str]: + """Split a table row on unescaped pipes; a backslash escapes the next character.""" + text = raw.strip() + cells: list[str] = [] + cell: list[str] = [] + i = 0 + while i < len(text): + ch = text[i] + if ch == "\\" and i + 1 < len(text): + cell.append(text[i:i + 2]) + i += 2 + continue + if ch == "|": + cells.append("".join(cell)) + cell = [] + else: + cell.append(ch) + i += 1 + cells.append("".join(cell)) + if text.startswith("|"): + cells = cells[1:] + if text.endswith("|") and not text.endswith("\\|") and cells: + cells = cells[:-1] + return [c.strip() for c in cells] + + +def _decode(cell: str) -> str: + return cell.replace("\\|", "|") + + +def _is_separator(cells: list[str], width: int) -> bool: + return len(cells) == width and all(_SEPARATOR_CELL_RE.match(c) for c in cells) + + +def _plain(cell: str) -> str: + """Cell text without surrounding backticks or emphasis.""" + value = _decode(cell).strip() + while len(value) >= 2 and value[0] == value[-1] and value[0] in "`*_": + value = value[1:-1].strip() + return value + + +def _tokens(cell: str) -> list[str] | None: + """IDs or test names in a reference cell; None when the cell states there are none.""" + value = _decode(cell).strip() + if value.lower() in EMPTY_SENTINELS or not value: + return None + tokens = [t.strip("`*_ ") for t in re.split(r"[,;\s]+", value)] + return [t for t in tokens if t] or None + + +def _is_retired_item(text: str) -> bool: + """An input FLOW or CANT item is retired only by its terminal '(retired)' marker.""" + return text.rstrip().lower().endswith("(retired)") + + +def _is_retired_row(cell: str) -> bool: + """A plan row marks an item retired only with an explicit 'retired:' prefix.""" + return _plain(cell).lower().startswith("retired:") + + +def _unquote(value: str) -> str | None: + for left, right in _QUOTES: + if len(value) >= 2 and value.startswith(left) and value.endswith(right): + return value[len(left):-len(right)] + return None + + +def _source_problem( + cell: str, flows: set[str] | None, retired: set[str], where: str, sentences: set[str] | None +) -> str | None: + """Return why a §2 Source cell is invalid, or None when it is valid. + + A source is either FLOW-n references or one Motivation sentence quoted in full, + never both. `sentences` is the input's Motivation sentences, or None without input. + """ + value = _decode(cell).strip() + if value.startswith(MOTIVATION_PREFIX): + return _motivation_problem(value[len(MOTIVATION_PREFIX):].strip(), sentences) + tokens = _tokens(value) + if tokens is None or not any(t.upper().startswith("FLOW") for t in tokens): + return SOURCE_SHAPE + for token in tokens: + if not FLOW_RE.match(token): + return f"requirement source contains invalid FLOW reference '{token}'" + for token in tokens: + if flows is None: + continue + if token not in flows: + return f"requirement source cites {token}, which is not in {where}" + if token in retired: + return f"requirement source cites {token}, which is retired in {where}" + return None + + +def _motivation_problem(rest: str, sentences: set[str] | None) -> str | None: + quote = _unquote(rest) + if quote is None: + return "Motivation source must enclose one sentence in double quotation marks" + text = _collapse(quote) + if not text: + return "Motivation source quote is empty" + if len(text) < MIN_QUOTE_CHARS or not any(ch.isalnum() for ch in text): + return f"Motivation source quote must contain at least {MIN_QUOTE_CHARS} normalized characters" + if _sentences(text) != [(text, True)]: + return "Motivation source quote must be one complete sentence" + if sentences is not None and text not in sentences: + return f"source quote is not a verbatim complete sentence from Motivation: \"{_short(text)}\"" + return None + + +def _motivation_sentences(motivation: str) -> set[str]: + """Complete sentences of the Motivation prose, whitespace collapsed. + + Comments, fenced code, headings and tables are dropped; list markers are removed + and each prose block or list item is joined across its wrapped lines. + """ + blocks: list[list[str]] = [] + current: list[str] | None = None + in_comment = False + fence: tuple[str, int] | None = None + lines = motivation.splitlines() + in_table = False + for index, line in enumerate(lines): + if not in_comment: + role, fence = fence_step(fence, line) + if role is not None: + current, in_table = None, False + continue + line, in_comment = _strip_comments(line, in_comment) + stripped = line.strip() + following = lines[index + 1] if index + 1 < len(lines) else "" + if not stripped: + current, in_table = None, False + continue + # A row followed by a delimiter row starts a table, which runs to the next blank line. + if _is_table_start(stripped, following): + in_table = True + if _SETEXT_RE.match(line): + # An underline turns the paragraph above it into a heading (or is a thematic break). + if current is not None and not in_table: + blocks.pop() # the current block is always the last one + current = None + continue + if in_table or _HEADING_RE.match(line) or stripped.startswith("|"): + current = None + continue + marker = _LIST_MARKER_RE.match(line) + if marker or current is None: + current = [] + blocks.append(current) + if marker: + stripped = line[marker.end():].strip() + current.append(stripped) + found: set[str] = set() + for block in blocks: + for sentence, complete in _sentences(_collapse(_decode(" ".join(block)))): + if complete: + found.add(sentence) + return found + + +def _sentences(text: str) -> list[tuple[str, bool]]: + """Split collapsed text into (sentence, complete) pairs. + + A sentence ends at '.', '!' or '?' (plus any closing quotes, brackets or emphasis) + followed by whitespace or the end of the text. Terminators inside inline code do not + count. A trailing piece without a terminator is returned as incomplete. + """ + result: list[tuple[str, bool]] = [] + start = 0 + i = 0 + n = len(text) + while i < n: + ch = text[i] + if ch == "`": + run = len(text[i:]) - len(text[i:].lstrip("`")) + # A code span closes only at a backtick run of exactly the opening length. + close = re.compile(rf"(?<!`)`{{{run}}}(?!`)").search(text, i + run) + i = close.end() if close else i + run + continue + if ch in ".!?": + end = i + 1 + while end < n and text[end] in ".!?": + end += 1 + while end < n and text[end] in _SENTENCE_CLOSERS: + end += 1 + if end == n or text[end].isspace(): + result.append((text[start:end].strip(), True)) + start = end + i = end + continue + i += 1 + tail = text[start:].strip() + if tail: + result.append((tail, False)) + return result + + +def _collapse(text: str) -> str: + """Treat any whitespace run as one space, so a quote need not follow line wrapping.""" + return re.sub(r"\s+", " ", text).strip() + + +def _short(text: str, limit: int = 80) -> str: + return text if len(text) <= limit else text[: limit - 3] + "..." + + +def _where(key: tuple[int, str | None]) -> str: + section, sub = key + return f"### {sub}" if sub else f"§{section}" + + +def lint_plan(text: str, input_document: InputDocument | None = None) -> list[Diagnostic]: + """Lint plan text; returns diagnostics in document order of discovery (empty = clean).""" + return _Linter(text, input_document).run() + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="lint.py", description=f"Lint a design plan against the plan template {TEMPLATE_VERSION}." + ) + parser.add_argument("plan", type=Path, help="plan Markdown file") + parser.add_argument("--input", type=Path, help="case input.md to check sources and coverage against") + parser.add_argument("--json", action="store_true", help="print a JSON report") + args = parser.parse_args(argv) + + def fail(message: str) -> int: + if args.json: + print(json.dumps({"ok": False, "error": message, "diagnostics": []}, indent=2)) + else: + print(f"error: {message}", file=sys.stderr) + return 2 + + try: + text = args.plan.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + return fail(f"cannot read {args.plan}: {exc}") + document = None + if args.input is not None: + try: + document = load_input(args.input) + except InputError as exc: + return fail(str(exc)) + diagnostics = lint_plan(text, document) + if args.json: + print(json.dumps({"ok": not diagnostics, "diagnostics": [d.as_dict() for d in diagnostics]}, indent=2)) + else: + for diagnostic in diagnostics: + print(diagnostic.text()) + return 1 if diagnostics else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/skills/design-plan/references/plan-template.md b/.claude/skills/design-plan/references/plan-template.md new file mode 100644 index 00000000..5d685bc7 --- /dev/null +++ b/.claude/skills/design-plan/references/plan-template.md @@ -0,0 +1,144 @@ +--- +plan_id: <short-stable-id> +perspective: <design perspective this plan takes> +inputs_ref: <case input and snapshot this plan was written against> +breaking: <true|false> +activation: <how and when the change takes effect, or why no activation step is needed> +surface: + external_functions: <n> + roles: <n> + storage_slots: <n> + config_params: <n> + errors: <n> + events: <n> +--- + +# Design plan (template v2) + +<!-- +How to fill this in (see README.md in this folder for the full conventions): +- Replace every <placeholder>. A finished plan contains no placeholders and no TBD. +- Keep every heading exactly as written, in this order. The readable part (sections 1 to 6) is for people: + concise technical English, code and diagrams instead of large tables. The reference part (sections 7 to 11) + is for tools and reviewers checking the details. +- Mermaid diagrams (```mermaid) are welcome wherever a flow is easier to see than to read. +- `breaking` is true if any existing function returns a different outcome for the same inputs. +- `surface` counts only additions: new external functions, roles, storage slots, configuration + parameters, errors and events. Reused items count zero. The counts equal the §8 New surface table. +--> + +## 1 Approach + +<The plan in a few short paragraphs: the design, and why it meets the Motivation, the user flows and the +constraints of the input.> + +## 2 Interface + +<One Solidity block with every new or changed function, type, error and event. End each declaration line with +`// new` or `// changed`, optionally followed by a short note; put a brief comment above it. Keep full sentences in +prose after the block. When nothing is added or changed, the block holds only the line `// No interface change.`> + +```solidity +// <Brief comment.> +function exampleName(<parameters>) external; // new +``` + +## 3 Changes + +<One subsection per behaviour change, titled in plain words and tagged with its ID at the end. The first line +names the functions it touches and whether their existing behaviour changes. Then the logic as numbered steps: +checks and their errors in order, state changes, events, and the role that authorizes every privileged action. +State the reason for any breaking change.> + +### <What changes, in a few words> (BEH-1) + +Functions: <`functionName`>; changes existing behaviour: <yes|no> + +1. <step> +2. <step> + +### Storage + +<One row per field. Status is `new`, `changed` or `unchanged`. Existing slots keep their meaning, type and +position; packing a field into free bits of an existing slot is described explicitly in Slot and Field. +Mock = Rust states that the Solidity mock and the Rust precompile use the identical layout (`yes`), or +explains the difference.> + +| ID | Slot | Field | Type | Status | Mock = Rust | +| --- | --- | --- | --- | --- | --- | +| ST-1 | <slot or derivation> | <field name> | <type and bit range> | <new> | <yes> | + +## 4 User flows + +<One subsection per FLOW-n in the input, in order, titled in plain words and tagged with its ID at the end. +Walk the flow end to end as numbered steps: setup, call, state changes, events, observable result. A retired +FLOW keeps its subsection and says "retired: not applicable" on its first line.> + +### <What the user does, in a few words> (FLOW-1) + +1. <setup> +2. <call> +3. <state change and events> +4. <observable result> + +## 5 What this plan gives up + +<Deliberate tradeoffs and what they cost, at a high level. A tradeoff never drops a requirement.> + +## 6 Open questions + +<Genuine open questions and their impact on the design, or "None". Do not hide unspecified behaviour here.> + +# Reference + +<!-- Sections 7 to 11 are read by tools and reviewers. Keep them exact and terse. --> + +## 7 Requirement trace + +<One row per requirement the design must meet, in your own words. Cite its source in one of two forms: the +FLOW-n items it serves (for example "FLOW-1, FLOW-3"; retired items cannot be cited), or the one Motivation +sentence it answers, quoted in full as Motivation: "the sentence". Do not mix the two forms in one row. Escape a +literal "|" inside a quote as "\|". Every Change ID must exist in §3.> + +| Requirement | Source | Change IDs | +| --- | --- | --- | +| <requirement the design meets> | <FLOW-1> | <BEH-1> | +| <requirement the design meets> | <Motivation: "complete Motivation sentence"> | <BEH-1, ST-1> | + +## 8 New surface + +<One row per addition counted in the front-matter `surface`. Kind is one of external_functions, roles, +storage_slots, config_params, errors, events. Functions, errors and events are declared in the §2 block. +Change IDs point at the §3 change that introduces the item; a storage slot cites its new ST row. Several fields +packed into one new slot count as one storage slot. Reused items are not listed.> + +| Kind | Name | Change IDs | +| --- | --- | --- | +| <external_functions> | <functionName> | <BEH-1> | + +## 9 Hot-path cost + +<One row per affected path plus the unchanged hot paths (transfer, transferFrom, mint, burn). Counts are +worst-case storage operations per call. Deltas are signed and relative to the pre-change snapshot. State the +condition behind each worst case, the slot behind each packing claim, and the contract each external call reaches.> + +| Function | Cold reads | Warm reads | Writes | External calls | Δ cold | Δ warm | Δ writes | Packed into a slot already read | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| <transfer> | <n> | <n> | <n> | <n> | <+0> | <+0> | <+0> | <yes: slot / no / n/a> | + +## 10 Invariants + +<One row per CANT-n in the input (keep retired items at their IDs and mark them retired), then G-INV-1 +(see ../invariants/global.md), then any NEW-n invariants this plan adds. Status is `holds` or `at risk`; +explain how it holds or why it is at risk, and cite the change IDs.> + +| ID | Status | How it holds | Change IDs | +| --- | --- | --- | --- | +| CANT-1 | <holds> | <explanation> | <BEH-1> | +| G-INV-1 | <holds> | <every call target the change uses is a precompile, or no calls> | <BEH-1> | +| NEW-1 | <holds> | <invariant this plan introduces> | <ST-1> | + +## 11 Interactions + +<Each existing mechanism or component the change touches (policies, roles, pause, allowances, other +precompiles, events consumers) and how it interacts. For mechanisms that do not apply, say why.> diff --git a/.claude/skills/design-plan/references/plan_inputs.py b/.claude/skills/design-plan/references/plan_inputs.py new file mode 100644 index 00000000..152cb7fe --- /dev/null +++ b/.claude/skills/design-plan/references/plan_inputs.py @@ -0,0 +1,260 @@ +"""Parse and validate a case input.md. + +An input has three required sections: Motivation, User flows to optimize for and +Must not break. The two requirement sections hold numbered items (FLOW-n for user +flows, CANT-n for must-not-break constraints) that later stages cite by ID, so +numbering is strict and retired items keep their slot. + +A Summary section is rejected: it is written after design and would hand designers +the shipped answer. The plan linter can opt in to legacy Summary-bearing inputs with +`allow_summary=True`; designer inputs never do. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from pathlib import Path + +SUMMARY = "Summary" +MOTIVATION = "Motivation" +FLOWS = "User flows to optimize for" +CANTS = "Must not break" +REQUIRED_SECTIONS = (MOTIVATION, FLOWS, CANTS) + +# Requirement sections and the item prefix each one owns. +_ITEM_SECTIONS = {FLOWS: "FLOW", CANTS: "CANT"} + +ITEM_RE = re.compile(r"^- (FLOW|CANT)-(\d+): .+") +_HEADING_RE = re.compile(r"^ {0,3}(#{1,6})[ \t]+(.*?)(?:[ \t]+#+)?[ \t]*$") +_FENCE_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$") +_TOP_BULLET_RE = re.compile(r"^[-*+](?:[ \t]|$)") +_IDLIKE_RE = re.compile(r"^[-*+][ \t]+(?:FLOW|CANT)\b", re.IGNORECASE) + + +class InputError(ValueError): + """Raised when an input file does not follow the input format.""" + + +@dataclass(frozen=True) +class InputItem: + id: str + text: str + + +@dataclass(frozen=True) +class InputDocument: + motivation: str + user_flows: str + must_not_break: str + flows: tuple[InputItem, ...] = field(default_factory=tuple) + cants: tuple[InputItem, ...] = field(default_factory=tuple) + + +@dataclass(frozen=True) +class LegacyInputDocument(InputDocument): + """An input that still carries a Summary; only the plan linter accepts one.""" + + summary: str = "" + + +@dataclass +class _Section: + title: str + level: int + start_line: int + lines: list[tuple[int, str]] = field(default_factory=list) + + +def fence_step(fence: tuple[str, int] | None, line: str) -> tuple[str | None, tuple[str, int] | None]: + """Classify one line given the open fence (or None); returns (role, new open fence). + + A fence opens on three or more backticks or tildes (a backtick opener may not contain + another backtick, so a one-line inline span is not a fence) and closes only on a run of + the same character at least as long as the opener followed by nothing but whitespace. + Roles are "delim" for a fence delimiter, "inside" for fenced content, None otherwise. + """ + match = _FENCE_RE.match(line) + if fence is None: + if match and not (match.group(1)[0] == "`" and "`" in match.group(2)): + return "delim", (match.group(1)[0], len(match.group(1))) + return None, None + if ( + match + and match.group(1)[0] == fence[0] + and len(match.group(1)) >= fence[1] + and not match.group(2).strip() + ): + return "delim", None + return "inside", fence + + +def fence_roles(lines: list[str]) -> list[str | None]: + """Classify each line as a fence delimiter ("delim"), fenced content ("inside") or neither.""" + roles: list[str | None] = [] + fence: tuple[str, int] | None = None + for line in lines: + role, fence = fence_step(fence, line) + roles.append(role) + return roles + + +def _is_summary(title: str) -> bool: + return title.casefold() == SUMMARY.casefold() + + +def _split_sections(lines: list[str], allow_summary: bool = False) -> dict[str, _Section]: + """Split into the required sections, ignoring headings inside code fences. + + An unfenced Summary heading at any level is an error unless `allow_summary` is set. + """ + known = REQUIRED_SECTIONS + ((SUMMARY,) if allow_summary else ()) + sections: dict[str, _Section] = {} + current: _Section | None = None + for number, (line, role) in enumerate(zip(lines, fence_roles(lines)), start=1): + if role is None: + heading = _HEADING_RE.match(line) + if heading: + level, title = len(heading.group(1)), heading.group(2).strip() + if _is_summary(title): + if not allow_summary: + raise InputError( + f"line {number}: a Summary section is not allowed " + f"(inputs hold only {', '.join(REQUIRED_SECTIONS)})" + ) + title = SUMMARY + if title in known: + if title in sections: + raise InputError(f"line {number}: duplicate section '{title}'") + current = sections[title] = _Section(title, level, number) + continue + if current is not None and level <= current.level: + current = None + continue + if current is not None: + current.lines.append((number, line)) + return sections + + +def _body(section: _Section) -> str: + text = "\n".join(line for _, line in section.lines) + return text.strip("\n") + + +def _in_fence_flags(section: _Section) -> list[bool]: + return [role is not None for role in fence_roles([line for _, line in section.lines])] + + +def _reject_stray_items(section: _Section) -> None: + """Requirement items are only valid in their own sections; elsewhere they would be dropped.""" + for (number, line), fenced in zip(section.lines, _in_fence_flags(section)): + if not fenced and _IDLIKE_RE.match(line): + raise InputError( + f"line {number}: FLOW/CANT item in '{section.title}' " + f"(items belong in '{FLOWS}' or '{CANTS}')" + ) + + +def _extract_items(section: _Section) -> tuple[InputItem, ...]: + prefix = _ITEM_SECTIONS[section.title] + items: list[InputItem] = [] + seen: set[int] = set() + for (number, line), fenced in zip(section.lines, _in_fence_flags(section)): + if fenced or not _TOP_BULLET_RE.match(line): + continue + match = ITEM_RE.match(line) + if match is None: + if _IDLIKE_RE.match(line): + raise InputError( + f"line {number}: malformed item in '{section.title}' " + f"(expected '- {prefix}-<n>: <text>')" + ) + raise InputError( + f"line {number}: item in '{section.title}' has no {prefix}-<n> ID" + ) + kind, digits = match.group(1), match.group(2) + item_id = f"{kind}-{digits}" + if kind != prefix: + raise InputError( + f"line {number}: {item_id} does not belong in '{section.title}'" + ) + if len(digits) > 1 and digits.startswith("0"): + raise InputError(f"line {number}: {item_id} is not a canonical ID") + text = line[len(f"- {item_id}: "):] + if not text.strip(): + raise InputError(f"line {number}: {item_id} has no description") + value = int(digits) + if value in seen: + raise InputError(f"line {number}: duplicate ID {item_id}") + expected = len(items) + 1 + if value != expected: + raise InputError( + f"line {number}: {item_id} out of sequence (expected {prefix}-{expected})" + ) + seen.add(value) + items.append(InputItem(item_id, text)) + if not items: + raise InputError(f"section '{section.title}' has no {prefix}-<n> items") + return tuple(items) + + +def parse_input(text: str, *, allow_summary: bool = False) -> InputDocument: + """Parse an input; with `allow_summary` a legacy Summary yields a LegacyInputDocument.""" + sections = _split_sections(text.splitlines(), allow_summary) + missing = [title for title in REQUIRED_SECTIONS if title not in sections] + if missing: + raise InputError("missing section(s): " + ", ".join(missing)) + for title in (SUMMARY, MOTIVATION): + if title in sections: + _reject_stray_items(sections[title]) + fields = dict( + motivation=_body(sections[MOTIVATION]), + user_flows=_body(sections[FLOWS]), + must_not_break=_body(sections[CANTS]), + flows=_extract_items(sections[FLOWS]), + cants=_extract_items(sections[CANTS]), + ) + if SUMMARY in sections: + return LegacyInputDocument(summary=_body(sections[SUMMARY]), **fields) + return InputDocument(**fields) + + +@dataclass(frozen=True) +class SourceLines: + """Physical 1-based lines that a hidden requirement may cite in its input.""" + + motivation: frozenset[int] + flows: dict[str, int] # active (not retired) FLOW ID -> its line + + +def source_lines(text: str) -> SourceLines: + """Locate citable lines: unfenced nonblank Motivation lines and active FLOW items. + + The text must be a valid designer input; parsing errors propagate as InputError. + """ + parse_input(text) + sections = _split_sections(text.splitlines()) + motivation = sections[MOTIVATION] + lines = frozenset( + number + for (number, line), fenced in zip(motivation.lines, _in_fence_flags(motivation)) + if not fenced and line.strip() and not _HEADING_RE.match(line) + ) + flows: dict[str, int] = {} + section = sections[FLOWS] + for (number, line), fenced in zip(section.lines, _in_fence_flags(section)): + match = None if fenced else ITEM_RE.match(line) + if match and not line.rstrip().lower().endswith("(retired)"): + flows[f"{match.group(1)}-{match.group(2)}"] = number + return SourceLines(lines, flows) + + +def load_input(path: Path, *, allow_summary: bool = False) -> InputDocument: + try: + text = Path(path).read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + raise InputError(f"cannot read {path}: {exc}") from exc + try: + return parse_input(text, allow_summary=allow_summary) + except InputError as exc: + raise InputError(f"{path}: {exc}") from exc diff --git a/.claude/skills/motivation-check/SKILL.md b/.claude/skills/motivation-check/SKILL.md new file mode 100644 index 00000000..9340b882 --- /dev/null +++ b/.claude/skills/motivation-check/SKILL.md @@ -0,0 +1,138 @@ +--- +name: motivation-check +description: "Check the quality of a change request in .council/input.md before design starts: does the Motivation say what real situation forces the change and why the current path fails there, is it free of design content and solution hints, does every FLOW-n name an actor, calls and an observable result, and can every CANT-n be checked from a plan. Live mode asks the user to fill gaps; eval mode writes .council/motivation-check.md." +version: 1.0.0 +--- + +# Motivation check + +Review the change request in `.council/input.md` and report gaps before anyone +designs a solution. This skill does not design, compare or pick plans, write an +ADR, implement code, or rewrite the input. + +## Input + +`.council/input.md` holds only three sections: + +- `## Motivation`: prose explaining why a change is needed. +- `## User flows to optimize for`: items `FLOW-1`, `FLOW-2`, ... +- `## Must not break`: items `CANT-1`, `CANT-2`, ... + +There is no Summary. Do not require one and do not draft one. + +Read the whole file. Never edit it. Quote section names and item IDs exactly as +written in every finding. If the file or a section is missing or empty, report +that as a finding; do not fill the gap with guessed content. An item marked as +retired stays listed under its ID with its retired status; do not check it as an +active requirement. + +## Check 1: the Motivation answers two questions + +Judge the Motivation against these two questions only, each on its own: + +1. **What real situation forces the change?** A concrete circumstance a user, + integrator or operator is actually in, not a wish for a feature. +2. **Why does the current path fail in that situation?** What happens today when + they try, and why that outcome is not acceptable. + +For each question, quote the sentence(s) that answer it, or flag the answer as +missing or unclear and say what fact is absent. Do not add other required +questions (business value, urgency, success metrics, proposed design). A +Motivation that answers both questions is sufficient even if it is short. + +## Check 2: no design content or solution hints in the Motivation + +The Motivation states the problem; designers choose the solution. Flag passages +that already pick an approach, such as: + +- a new or changed interface, function, parameter, event or error; +- a data layout, storage scheme or encoding; +- an algorithm, component split or sequence of implementation steps; +- wording such as "we should add", "by introducing", "the fix is" that settles + the answer. + +Describing current behavior in technical terms is fine: "calls to X revert when Y" +explains a present limitation. Flag only proposed mechanisms. For each finding, +quote the passage and say why it narrows the design space. Do not offer a +replacement design and do not rewrite the Motivation. + +## Check 3: every FLOW-n is concrete + +For each active FLOW-n, confirm it names: + +- **an actor**: who performs the flow (a role, account type or calling contract); +- **the calls**: the actions or calls they make, in order; +- **an observable result**: what they or an outside observer can see afterwards + (returned data, state that can be read, emitted events, a clear failure). + +Name exactly which element is missing. Calls may be described at the level of +intent ("registers a policy, then queries it"); do not invent signatures or +demand implementation detail to make a flow concrete. + +## Check 4: every CANT-n is checkable from a plan + +For each active CANT-n, confirm a reviewer could hold a proposed plan against it +and decide whether the plan keeps it. A checkable item says: + +- what behavior or property must stay true; +- under which conditions or for which callers; +- what a plan would have to show to demonstrate it (for example an unchanged + interface, preserved stored data, an existing caller still succeeding). + +Flag vague assurances ("nothing should break", "stay secure", "keep performance +good") and say what would make them checkable. Do not require a plan to exist, +do not assume a particular design, and do not claim any plan passes. + +## Modes + +### Live mode (a user is present) + +Present the findings in the report structure below. Then ask the user only for +the missing facts or clarifications the findings name, grouped by section. The +user owns the input; suggest what is missing, but do not silently edit +`.council/input.md`. If there are no findings, say the input is ready and ask +nothing. + +### Eval mode (unattended) + +Never ask questions, never wait for a reply, and never edit +`.council/input.md`. Always write `.council/motivation-check.md`, creating the +`.council/` directory if needed, even when the input is clean or missing. Record +open gaps as findings, not as questions to the user. Do not infer facts the input +does not state. + +## Report structure + +Use this structure for `.council/motivation-check.md` (eval mode) and for the +findings shown to the user (live mode). Write "No findings." under any section +that has none. + +```markdown +# Motivation check + +## Readiness +Ready | Needs input. One sentence on the main gap, if any. + +## Motivation: forcing situation +Answered | Missing | Unclear. Quote the evidence, or state the missing fact. + +## Motivation: why the current path fails +Answered | Missing | Unclear. Quote the evidence, or state the missing fact. + +## Design content and solution hints +- "<quoted passage>": why it presupposes a solution. + +## User flows +| ID | Actor | Calls | Observable result | Finding | +|----|-------|-------|-------------------|---------| + +## Must not break +| ID | Checkable | Finding | +|----|-----------|---------| + +## Missing information +- <section or ID>: the fact the author needs to supply. +``` + +In the tables, mark each element present or missing and give the reason in the +Finding column. List retired items with the status "retired" and no finding. diff --git a/.claude/skills/technical-document/SKILL.md b/.claude/skills/technical-document/SKILL.md deleted file mode 100644 index b60a258a..00000000 --- a/.claude/skills/technical-document/SKILL.md +++ /dev/null @@ -1,249 +0,0 @@ ---- -name: technical-document -description: "Convert changelog/TEMPLATE_POINT_FORM.md from point form into a written technical specification. Use the exact template structure. Apply ASD-STE100-inspired Simplified Technical English and Google technical-writing guidance while preserving technical facts, uncertainty, and scope." -version: 2.0.0 ---- - -# Convert the point-form template into a written specification - -Use this skill for one task only: convert the repository template -`changelog/TEMPLATE_POINT_FORM.md` into a complete written specification. - -Do not use this skill to create a generic RFC, design document, technical overview, -or implementation plan. The template is the source of truth for the output structure. - -## Source template - -Read and follow: - -```text -changelog/TEMPLATE_POINT_FORM.md -``` - -Also follow the repository guidance in: - -```text -changelog/AGENTS.md -changelog/README.md -``` - -## Required reading before drafting - -Before writing any output, read every file below in full. These are the bundled, -offline copies of this skill's writing rules — use them as the authoritative source -instead of fetching the external sites they summarize: - -```text -references/template-point-form.md -references/simplified-technical-english.md -references/google-technical-writing.md -``` - -Do not skip this reading pass, and do not substitute a `WebFetch` of the external URLs -named in Steps 5 and 6 for reading these files — the referenced sites may be unreachable, -paywalled, or have drifted from what this skill applies, and the bundled files are the -version this skill is designed against. - -## Exact output structure - -The output must use this structure and heading text in this order: - -```markdown -# [Title] - -- **Feature Name**: [value] -- **Start Date**: [value] -- **Authors**: [value] -- **Title**: [value] - -## Summary - -## Motivation - -## Background - -## Specs - -### Interface Changes - -### Behavioural Changes - -### Examples - -## Design Decisions & Alternatives Considered - -## Migration Steps -``` - -Template conformance is mandatory: - -- Use the exact heading text and heading order. -- Keep all required headings, even when a section contains a TODO. -- Do not add headings such as `Goals`, `Risks`, `Open Questions`, `Decision`, - `Rollout`, or `Appendix`. -- Put content that would normally use those headings into the closest defined section. -- Add headings under `## Specs` only when the source template or user explicitly requires them. -- Do not add generic preambles, summaries, transformation notes, or closing reports. -- Return only the completed specification unless the user explicitly asks for an audit or explanation. - -## Conversion workflow - -### 1. Read the complete input - -Read the entire point-form document before drafting. Extract: - -- Feature name, start date, authors, and title. -- Summary and audience. -- Problem, motivation, and current limitations. -- Background concepts, prior art, standards, and existing patterns. -- Interface changes, including functions, events, errors, signatures, and selectors. -- Behavioral changes, including execution flow, storage, compatibility, and gas effects. -- Examples, expected values, emitted events, and call sequences. -- Design decisions and rejected alternatives. -- Migration steps, deprecations, compatibility, and breaking changes. - -Treat comments in the template as instructions. Do not copy those comments into the output. - -### 2. Map content to the fixed sections - -Use this mapping: - -- Metadata → document metadata. -- Summary bullets → `## Summary`. -- Problem and rationale → `## Motivation`. -- Prerequisites, prior art, standards, and terminology → `## Background`. -- Interface facts → `### Interface Changes`. -- Runtime, storage, gas, and compatibility facts → `### Behavioural Changes`. -- Before/after snippets and call sequences → `### Examples`. -- Chosen approach and rejected options → `## Design Decisions & Alternatives Considered`. -- Integrator adoption and rollout actions → `## Migration Steps`. - -Do not move content to a new heading to make the document easier to organize. - -### 3. Preserve technical fidelity - -- Preserve every fact, number, condition, qualifier, and scope boundary. -- Preserve uncertainty. Keep `may`, `could`, `estimated`, and `unknown` unchanged in strength. -- Do not invent selectors, topic values, signatures, storage slots, gas costs, owners, dates, or compatibility claims. -- Mark missing information with `[TODO: ...]` in the relevant section. -- Mark an inference with `[Inferred — verify: ...]`. -- If a technical value is not verified, write `[TODO: verify against source]`. -- Keep code and signatures exactly as provided unless the user asks for a correction. - -Before citing a function signature, event signature, error selector, topic0, function -selector, or ERC-165 interface ID, verify it against the source or mark it for verification. - -### 3a. Offer to help fill `### Interface Changes` and `### Examples` gaps - -`### Interface Changes` and `### Examples` are the two sections most likely to need facts -the point-form input does not supply in full: exact signatures, selectors, topic0 values, -or a working call sequence. When either section would otherwise ship with a `[TODO: ...]` -or `[TODO: verify against source]` marker, stop before finalizing that section and ask the -user whether they want help closing the gap — for example, whether you should search the -repository (`src/interfaces/**` and related source) to find or verify the missing -signature, selector, or example, or whether they would rather supply the value themselves -or leave the TODO in place. - -- Ask once per document, covering all outstanding interface/example gaps together, rather - than once per gap. -- If the user says yes, read the relevant source before writing the section, and verify - each value you cite rather than guessing. -- If the user declines, or doesn't respond, keep the `[TODO: ...]` markers rather than - inventing a plausible-looking signature, selector, or example. -- Do not ask about gaps elsewhere in the document (Summary, Motivation, Background, Design - Decisions, Migration Steps) — only Interface Changes and Examples warrant this offer. - -### 4. Write the specification - -Convert point-form fragments into complete technical prose. Keep the meaning and -technical detail, but improve structure, readability, and consistency. - -Use tables for mappings when the input contains multiple comparable entries. Use -numbered lists for migration steps. Use code blocks for verified code or call sequences. - -### 5. Apply Simplified Technical English principles - -Apply the rules in `references/simplified-technical-english.md` (read in the required -reading pass above). That file is the source of truth for this step — do not fetch the -public `asd-ste100-skill` repository it summarizes. - -Apply these rules: - -- Prefer active voice and name the actor when the actor is known. -- Use one clear action per sentence. -- Keep procedures near 20 words per sentence and descriptions near 25 words when possible. -- Split long or compound sentences when splitting preserves precision. -- Do not use semicolons in procedural text. -- Keep subjects, verbs, articles, and conditions explicit. -- Put conditions before instructions. -- Replace ambiguous phrasal verbs such as `spin up` and `kick off` with plain verbs. -- Use one term for one technical concept. Do not rotate synonyms. -- Prefer direct, common words over formal or vague alternatives. -- Preserve technical terms when simplifying them would reduce accuracy. - -Apply structural rules confidently. Treat dictionary-specific lexical rules as guidance -only. Never claim that the output is officially ASD-STE100 compliant, and never reproduce -the official ASD-STE100 dictionary. - -### 6. Apply Google technical-writing guidance - -Apply the checklist in `references/google-technical-writing.md` (read in the required -reading pass above). That file is the source of truth for this step — do not fetch -developers.google.com. - -Apply these rules: - -- Write for the technical reader’s role and existing knowledge. -- State the purpose and scope in `Summary` and `Motivation`. -- Use clear, descriptive sentence-case headings from the template. -- Put important information before supporting detail. -- Use plain, globally understandable English. -- Define acronyms and unfamiliar domain terms at first use. -- Use numbered lists for ordered steps and bullets for unordered information. -- Avoid idioms, slang, cultural references, buzzwords, and marketing language. -- Replace vague claims with measurements when the input provides measurements. -- Use descriptive link text and unambiguous dates. - -### 7. Review before returning - -Check all of the following: - -- The output matches the exact template heading structure. -- No template heading is missing, renamed, duplicated, or reordered. -- No unsupported technical fact was added. -- No source fact, qualifier, or condition was removed. -- The output contains no invented selector, signature, storage detail, or compatibility claim. -- Interface changes are separated from behavioral changes. -- Examples contain only supplied or verified values. -- Migration steps identify compatibility and breaking changes when the input provides them. -- Missing information uses TODO markers. -- Any Interface Changes/Examples gap was offered to the user per Step 3a before shipping a TODO. -- Active voice, terminology consistency, sentence clarity, and plain language were applied. - -## Output rules - -Return only the written specification by default. Do not explain the conversion and do -not add headings outside the template. - -If the user asks for an audit, return the conforming specification first. Add the audit -after a plain-text separator such as `--- Review ---`, and do not treat the audit as part -of the specification. - -## Boundaries - -This skill will not: - -- Generate other document types. -- Invent technical facts or evidence. -- Claim official ASD-STE100 compliance. -- Reproduce the ASD-STE100 approved-word dictionary. -- Verify repository behavior without reading the relevant source. -- Manufacture selectors, signatures, storage layouts, or migration guarantees. - -## References - -- `references/google-technical-writing.md` -- `references/template-point-form.md` -- `references/simplified-technical-english.md` -- `examples/point-form-input.md` -- `examples/generated-spec.md` diff --git a/.claude/skills/technical-document/examples/generated-spec.md b/.claude/skills/technical-document/examples/generated-spec.md deleted file mode 100644 index 1cdb6c9c..00000000 --- a/.claude/skills/technical-document/examples/generated-spec.md +++ /dev/null @@ -1,57 +0,0 @@ -# Add a temporary worker pool for release builds - -- **Feature Name**: temporary_release_worker_pool -- **Start Date**: 2026-08-18 -- **Authors**: [TODO: provide author] -- **Title**: Add a temporary worker pool for release builds - -## Summary - -Release periods increase build wait time because normal builds and release builds -share one worker pool. This specification defines a temporary worker pool for -release builds. - -## Motivation - -The change reduces release-build wait time without adding permanent workers. The -current wait-time baseline is `[TODO: provide the baseline]`. - -## Background - -One shared worker pool handles normal builds and release builds. The release-period -threshold and available worker capacity are `[TODO: provide the missing values]`. - -## Specs - -### Interface Changes - -No interface changes are currently identified. - -### Behavioural Changes - -The system adds a temporary worker pool for release builds. The system limits the -pool size so release builds cannot use all available capacity. The temporary pool -could starve normal builds. - -### Examples - -Test the pool with one repository before expanding it. The expected wait-time target -is `[TODO: provide the target]`. - -## Design Decisions & Alternatives Considered - -The preferred approach adds a temporary release worker pool. This approach avoids -permanent worker capacity but adds queue configuration and a starvation risk. - -The alternative keeps one pool and increases its size. This approach preserves the -current queue model but adds permanent worker capacity. - -## Migration Steps - -1. Measure the current release-build wait time. -2. Configure the temporary pool for one repository. -3. Monitor normal-build wait time during the pilot. -4. Expand the pool after validation. - -Disable the temporary pool to roll back the change. The rollback owner and trigger -are `[TODO: provide the missing values]`. diff --git a/.claude/skills/technical-document/examples/point-form-input.md b/.claude/skills/technical-document/examples/point-form-input.md deleted file mode 100644 index 22d79415..00000000 --- a/.claude/skills/technical-document/examples/point-form-input.md +++ /dev/null @@ -1,49 +0,0 @@ -# Point-form input for TEMPLATE_POINT_FORM - -- **Feature Name**: temporary_release_worker_pool -- **Start Date**: 2026-08-18 -- **Authors**: [TODO: provide author] -- **Title**: Add a temporary worker pool for release builds - -## Summary - -- Release periods increase build wait time. -- Normal builds and release builds share one worker pool. - -## Motivation - -- Reduce release-build wait time. -- Avoid adding permanent workers. - -## Background - -- One shared worker pool handles normal builds and release builds. -- The current wait-time baseline is not available. - -## Specs - -### Interface Changes - -- No interface changes are currently identified. - -### Behavioural Changes - -- Add a temporary worker pool for release builds. -- Limit the release-pool size. -- The temporary pool could starve normal builds. - -### Examples - -- Test the pool with one repository before expanding it. - -## Design Decisions & Alternatives Considered - -- Preferred approach: add a temporary release worker pool. -- Alternative: keep one pool and increase its size. -- Mitigation: set a maximum release-pool size. - -## Migration Steps - -- Test with one repository. -- Expand after validation. -- The target wait time is not defined. diff --git a/.claude/skills/technical-document/references/template-point-form.md b/.claude/skills/technical-document/references/template-point-form.md deleted file mode 100644 index ac6dcb29..00000000 --- a/.claude/skills/technical-document/references/template-point-form.md +++ /dev/null @@ -1,40 +0,0 @@ -# Point-form template mapping - -The canonical source template is: - -```text -changelog/TEMPLATE_POINT_FORM.md -``` - -The skill converts the template into prose without changing its structure. - -## Section mapping - -| Source content | Written specification section | -| --- | --- | -| Feature name, start date, authors, title | Document metadata | -| Summary bullets | `## Summary` | -| Problem and rationale | `## Motivation` | -| Prior art, standards, prerequisites, terminology | `## Background` | -| Functions, events, errors, signatures, selectors | `### Interface Changes` | -| Execution, storage, gas, compatibility | `### Behavioural Changes` | -| Before/after snippets and call sequences | `### Examples` | -| Chosen approach and rejected options | `## Design Decisions & Alternatives Considered` | -| Integrator adoption and compatibility actions | `## Migration Steps` | - -## Required headings - -```text -## Summary -## Motivation -## Background -## Specs -### Interface Changes -### Behavioural Changes -### Examples -## Design Decisions & Alternatives Considered -## Migration Steps -``` - -Do not add headings such as `Goals`, `Risks`, `Open Questions`, `Decision`, or -`Rollout`. Place that content in the closest required section.