From 19b680a42398011afdd1c75634eb9f0ac7b44331 Mon Sep 17 00:00:00 2001 From: Brett Tully Date: Thu, 6 Aug 2026 22:01:56 +1000 Subject: [PATCH 01/14] AM-1321 Add context-economy as a single-copy reference and cut the spec summary comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New skills/shared/references/context-economy.md is the one place the attention-budget principle, the two cut tests, write-to-the-actor, no-cross-part-restatement, and evidence-vs-instruction are stated; every authoring surface carries a pointer only. /sy:spec §7 Step 2 no longer posts the Step-1 summary back as a second comment restating a plan the user just approved, and Step 1's auto-mode consent sentence no longer authorizes that write. "never writes the Task body" moves into Step 2's lead-in so validate.py's existing assertion stays satisfiable. spec-gate's Simplicity axis gains two objective prose triggers; no seventh axis. Co-Authored-By: Claude Opus 5 --- .claude-plugin/plugin.json | 2 +- CONTRIBUTING.md | 2 +- docs/usage.md | 2 +- scripts/validate.py | 41 ++++++++++++++++++++ skills/plan/SKILL.md | 2 +- skills/pr/SKILL.md | 2 + skills/shared/references/context-economy.md | 30 ++++++++++++++ skills/shared/references/spec-gate.md | 2 +- skills/ship/references/handoff-accounting.md | 2 +- skills/spec/SKILL.md | 11 +++--- 10 files changed, 84 insertions(+), 12 deletions(-) create mode 100644 skills/shared/references/context-economy.md diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index c863a7e..7f7dde6 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "sy", "displayName": "Shipyard", - "version": "1.18.0", + "version": "1.19.0", "description": "Disciplined plan -> spec -> ship workflow for Claude Code: adversarial review against pinned commits, briefs-not-transcripts context hygiene, and a full paper trail on a pluggable issue tracker (Jira or GitHub Projects).", "author": { "name": "Brett Tully" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d3bb574..847dd69 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ Contributions are small, verifiable, and keep the tracker seam clean. This guide 4. Load the plugin locally to try it: `claude --plugin-dir /path/to/shipyard`. 5. Commands are namespaced by the plugin name (`sy`): `/sy:plan`, `/sy:spec`, `/sy:ship`, `/sy:spike`, `/sy:pr`, `/sy:ci`, `/sy:explain`, `/sy:help`, `/sy:init-repo`, `/sy:config`. -Keep prose (READMEs, roadmaps, docs) clear and unwrapped; keep machine-facing text (agent briefs, contracts, JSON logs) terse and structured. +Keep prose (READMEs, roadmaps, docs) clear and unwrapped; keep machine-facing text (agent briefs, contracts, JSON logs) terse and structured. How hard to cut either, and the two tests to cut by, is `skills/shared/references/context-economy.md`. ## Comments and docstrings diff --git a/docs/usage.md b/docs/usage.md index 822e77d..411a93d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -38,7 +38,7 @@ Re-enter later with `/sy:plan ` to read what shipped since the last checkp `` is an issue ID — a Jira key like `PROJ-123` or a GitHub issue like `#123`. `/sy:spec` reads the ticket and the code, resolves the repo's engineering standards, pulls representative data when shape matters, and asks you only when research cannot settle a decision. It writes a complete plan — the approach, the strongest rejected alternative (pressure-tested by the same debate, which runs on every plan before sign-off), ordered changes with file anchors, tests, acceptance criteria, a verification obligation for every activated risk lens, and the docs, visual, and pre-gate-checkpoint obligations the change makes necessary — then has a separate `sy:spec-gate` reviewer read that drafted plan for architecture, simplicity, correctness, and those last three fields before you ever see it. -You approve the plan before anything is built, and what you are asked to approve is a short prose summary of the judgment calls rather than the full file inventory. On approval the mechanical plan lands on the ticket as the single ACTIVE execution plan, stamped with the commit it was planned against. That same prose summary is posted as a comment on the ticket — the body is left untouched, so a reporter's repro steps or a PM's acceptance notes are never a target of the write. The task moves to `ready`, and the plan ends with a `/sy:ship` kickoff and a ship profile that names each phase's model explicitly (`START / BUILD / GATE / effort / process `), so `/sy:ship` passes each phase's model through as an actual override instead of inferring one. +You approve the plan before anything is built, and what you are asked to approve is a short prose summary of the judgment calls rather than the full file inventory. On approval the mechanical plan lands on the ticket as the single ACTIVE execution plan, stamped with the commit it was planned against. That plan comment is the only thing written — the ticket body is left untouched, so a reporter's repro steps or a PM's acceptance notes are never a target of the write, and the prose summary you just approved is not posted back as a second comment restating it. The task moves to `ready`, and the plan ends with a `/sy:ship` kickoff and a ship profile that names each phase's model explicitly (`START / BUILD / GATE / effort / process `), so `/sy:ship` passes each phase's model through as an actual override instead of inferring one. Not every spec ends in a plan. When research shows the premise is already delivered, invalidated, or superseded, spec shelves the task instead: it posts the decisive evidence as a comment and closes the task rather than producing a plan for work that should not ship. diff --git a/scripts/validate.py b/scripts/validate.py index 352c358..e31590e 100755 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -116,6 +116,7 @@ "skills/shared/references/model-dispatch.md", "skills/shared/references/config-values.md", "skills/shared/references/transcript-attach.md", + "skills/shared/references/context-economy.md", "skills/plan/references/new-objective.md", "skills/plan/references/reentry.md", "skills/plan/references/roadmap-shaping.md", @@ -516,6 +517,8 @@ def read(rel: str) -> str: preflight_ref = read("skills/shared/references/preflight.md") debate_ref = read("skills/shared/references/debate.md") spec_gate_ref = read("skills/shared/references/spec-gate.md") + economy_ref = read("skills/shared/references/context-economy.md") + contributing = read("CONTRIBUTING.md") roadmap_shaping = read("skills/plan/references/roadmap-shaping.md") tracker_skill = read("skills/tracker/SKILL.md") jira_adapter = read("skills/tracker/jira/ADAPTER.md") @@ -746,6 +749,44 @@ def read(rel: str) -> str: fail("spec §7's Step 2 procedure must state it never writes the Task body", errors) if "Step 2 — after approval" not in spec: fail("spec must keep the staged reveal: full plan posted only in Step 2, after approval", errors) + # §7 Step 2's summary comment was removed: it restated, on the ticket, a summary the user had just + # read and approved. Both spellings are pinned because the instruction lived in two places — Step 2's + # numbered procedure and Step 1's auto-mode consent sentence — and a consent sentence still naming a + # write the run no longer performs states a false authorization. + if "post the Step-1 summary" in spec_s7: + fail("spec §7 must not post the Step-1 summary back as a second comment restating the plan", errors) + if "post this summary as a comment on the Task" in spec: + fail("spec's sign-off consent sentence names a summary-comment write §7 Step 2 no longer performs", errors) + + # Context economy is a single copy: the two cut tests are phrased once, in the reference, and every + # authoring surface carries a pointer to it. A consumer that spells a cut test out has forked the rule. + cut_tests = ( + "Does removing this sentence change what its reader does?", + "Would a pointer do the work this text is doing?", + ) + for cut_test in cut_tests: + if cut_test not in economy_ref: + fail(f"context-economy.md must state the cut test {cut_test!r} verbatim", errors) + economy_consumers = ( + ("spec", spec), + ("plan", plan), + ("pr", pr), + ("handoff-accounting", handoff), + ("CONTRIBUTING.md", contributing), + ) + for name, text in economy_consumers: + if "context-economy.md" not in text: + fail(f"{name} authors an agent-facing artifact and must cite context-economy.md", errors) + for cut_test in cut_tests: + if cut_test in text: + fail(f"{name} restates a context-economy cut test; cite context-economy.md instead", errors) + economy_axis_phrase = "narrates what a cited anchor already shows" + if economy_axis_phrase not in spec_gate_ref: + fail(f"spec-gate reference's Simplicity axis must state the prose trigger {economy_axis_phrase!r}", errors) + if economy_axis_phrase in spec_gate: + fail("spec-gate agent restates the prose-economy trigger; cite spec-gate.md instead of copying it", errors) + if economy_axis_phrase in spec: + fail("spec restates the prose-economy trigger; cite spec-gate.md instead of copying it", errors) # `post-comment` takes `human` and `agent_detail`, both required, and assembles the boundary itself. # These are the highest-traffic call sites, so a reference still describing one hand-composed body diff --git a/skills/plan/SKILL.md b/skills/plan/SKILL.md index 7ef3d63..f2ec9d4 100644 --- a/skills/plan/SKILL.md +++ b/skills/plan/SKILL.md @@ -36,7 +36,7 @@ Read durable cross-session memory early — `memory_list` (or `memory_search` on Seed every agent prompt with known anchors — paths, symbols, entry points, keys — and name ground already covered; agents must not rediscover what the caller knows. -Machine-facing agent briefs stay pointer-dense. Human-facing Epic maps and decision logs remain clear prose. +Machine-facing agent briefs stay pointer-dense. Human-facing Epic maps and decision logs remain clear prose. Both, and the roadmap and `# SEAMS` comments this skill posts, are written under `${CLAUDE_PLUGIN_ROOT}/skills/shared/references/context-economy.md`. ## State router diff --git a/skills/pr/SKILL.md b/skills/pr/SKILL.md index 7568cf2..367e3a9 100644 --- a/skills/pr/SKILL.md +++ b/skills/pr/SKILL.md @@ -36,6 +36,8 @@ Base on real diff/log. The mutable description is the PR's human-attention secti **Acceptance criteria/evidence never live only in the mutable description.** `/sy:ship` posts them as a dedicated PR comment so promotion/refresh cannot erase them. +What belongs in which of those two, and how hard to cut each, is `${CLAUDE_PLUGIN_ROOT}/skills/shared/references/context-economy.md`. + Title: ` - ` when branch carries a ticket key. ## 3. Review threads diff --git a/skills/shared/references/context-economy.md b/skills/shared/references/context-economy.md new file mode 100644 index 0000000..61816ea --- /dev/null +++ b/skills/shared/references/context-economy.md @@ -0,0 +1,30 @@ +# Context economy + +Every agent-facing artifact Shipyard writes is loaded into a finite budget and read by a model that gets worse at using it the fuller it gets. Anthropic's guidance names the resource directly — "LLMs have an 'attention budget' that they draw on when parsing large volumes of context" — and sets the target as finding "the smallest possible set of high-signal tokens that maximize the likelihood of some desired outcome" (https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents). This file is the single copy of that rule for Shipyard's own artifacts: skills, agent briefs, plans, roadmaps, PR descriptions, state briefs, handoff records. Cite it from wherever an artifact gets authored; never restate it there. + +The cost is measured, not stylistic. Chroma's *Context Rot* evaluated 18 LLMs across 194,480 calls and found that "even under these minimal conditions, model performance degrades as input length increases" — on tasks trivial enough that length was the only variable (https://www.trychroma.com/research/context-rot). Position compounds volume: performance "is often highest when relevant information occurs at the beginning or end of the input context, and significantly degrades when models must access relevant information in the middle" (Liu et al., https://arxiv.org/abs/2307.03172). And instructions dilute each other — IFScale reports that "even the best frontier models only achieve 68% accuracy at the max density of 500 instructions", with later instructions dropped more often than earlier ones (https://arxiv.org/abs/2507.11538). A sentence added to a brief is not free: it competes with every other sentence there, and it pushes something else toward the middle. + +## The two cut tests + +Apply both to every paragraph before it ships: + +1. **Does removing this sentence change what its reader does?** If nothing downstream changes, it is commentary, and it goes. +2. **Would a pointer do the work this text is doing?** If the content already lives somewhere the reader can reach, cite that instead of copying it. + +## Write to the actor + +A settled decision is stated, not re-argued. Once the choice is made, the artifact tells its reader what to do; the reasoning that produced it belongs to the human-facing half of the record, or nowhere. Rationale in machine-facing text is the most common form of dilution here precisely because it reads as thoroughness — but an implementer cannot act on *why*, and every line of it displaces a line they could have acted on. + +## No cross-part restatement + +An artifact with two labeled parts — a human half and an agent half, a sign-off summary and a mechanical plan — carries each fact in exactly one of them. Repetition across that boundary is not redundancy for safety; it is two copies that drift, and a reader of either half cannot tell which one is current. Cross-reference across the boundary instead of copying over it. + +## Evidence is not instruction + +Forensic detail — the trace that established a fact, the counts, the hypotheses ruled out — is evidence for a claim, not an instruction to anybody. It earns a place inline only where a reader acts on it; otherwise it belongs in a companion record that nothing depends on. + +In Shipyard that has a hard edge. A pointer to another tracker comment resolves to nothing for every phase after START, which is the only phase that reads the ticket. A fact a later phase needs is carried in that phase's own brief or it is not available at all — so "it's in the investigation comment" is not a way to keep detail without paying for it. Either the detail earns its place inline, or it goes in a companion comment the plan does not depend on. + +## The instance already in the tree + +Shipyard applies this to itself: the `## Return contract — target ≤N tokens` block in every `agents/*.md` is this principle made enforceable at the one point where an agent's output enters someone else's budget. diff --git a/skills/shared/references/spec-gate.md b/skills/shared/references/spec-gate.md index a10068f..8623370 100644 --- a/skills/shared/references/spec-gate.md +++ b/skills/shared/references/spec-gate.md @@ -11,7 +11,7 @@ The checklist below is the single copy. Cite this file from the dispatch prompt Three are reviewer judgment — the reviewer has to think, and a pass with nothing found is a real result: 1. **Architecture.** Does the change sit at the right altitude and on the right seam? Look for a new parallel path beside an existing one, a primitive reimplemented instead of reused, a responsibility landing in a layer that should not own it, and coupling the plan introduces but never names. -2. **Simplicity.** Is this the smallest change that delivers the goal? Look for a config toggle where an unconditional behaviour would do, a new abstraction with one caller, ordered steps that collapse into one, and scope the goal does not require. +2. **Simplicity.** Is this the smallest change that delivers the goal? Look for a config toggle where an unconditional behaviour would do, a new abstraction with one caller, ordered steps that collapse into one, and scope the goal does not require. The plan's own prose is in scope, on two objective triggers: prose restating a decision argued elsewhere in the same plan, and prose that narrates what a cited anchor already shows (`${CLAUDE_PLUGIN_ROOT}/skills/shared/references/context-economy.md`). Neither is a length budget — a long plan whose every paragraph is load-bearing passes this axis. 3. **Correctness.** Do the ordered changes actually produce the stated outcome? Look for a step whose stated effect its cited anchor cannot have, an invariant the plan breaks elsewhere while protecting it here, an ordering that leaves an intermediate state broken, and an acceptance criterion that would pass without the behaviour existing. Three are required-field completeness — the plan's `/sy:ship` section either carries the field or it does not: diff --git a/skills/ship/references/handoff-accounting.md b/skills/ship/references/handoff-accounting.md index 6b026f4..f56c897 100644 --- a/skills/ship/references/handoff-accounting.md +++ b/skills/ship/references/handoff-accounting.md @@ -2,7 +2,7 @@ This phase runs mostly as a worker for the records and accounting. The readable transcript is rendered from the on-disk session tree by a delegate, so no manual `/export` is ever run. -Create the durable records for the plan's process tier, each as its own tracker comment, never combined: `full` = all four below; `light` = records 1–3 only, with `transcript_attachment: null` in the metrics JSON. The tier never changes CI/review coverage. Record 4 has a second, independent gate on top of tier — see §4. +Create the durable records for the plan's process tier, each as its own tracker comment, never combined: `full` = all four below; `light` = records 1–3 only, with `transcript_attachment: null` in the metrics JSON. The tier never changes CI/review coverage. Record 4 has a second, independent gate on top of tier — see §4. Every record here, and every state brief and handoff record this phase hands on, is written under `${CLAUDE_PLUGIN_ROOT}/skills/shared/references/context-economy.md`. ## Doc-accuracy self-check (before the retro) diff --git a/skills/spec/SKILL.md b/skills/spec/SKILL.md index 0233a02..6e32512 100644 --- a/skills/spec/SKILL.md +++ b/skills/spec/SKILL.md @@ -43,7 +43,7 @@ Draft Summary, Context/constraints, and Out of scope. Write the body as short na ### Existing Task -Read its body/comments directly and preserve settled decisions. Delegate only large parent-Epic or PR tails to `sy:sweep`. Edit the body only when research changes framing, and then as a deliberate full rewrite authored from scratch — never a merge around body content read back from the tracker, and it does not carry over rich text a description read cannot represent (see the selected adapter's `ADAPTER.md` on what that read does not guarantee). That governs research-phase edits; §7 Step 2 never writes the Task body at all, and posts its summary as a comment instead. Ensure the parent Epic is `in-progress` when active work begins; the Task stays in `backlog` until its plan is approved (then `ready`, per step 7). +Read its body/comments directly and preserve settled decisions. Delegate only large parent-Epic or PR tails to `sy:sweep`. Edit the body only when research changes framing, and then as a deliberate full rewrite authored from scratch — never a merge around body content read back from the tracker, and it does not carry over rich text a description read cannot represent (see the selected adapter's `ADAPTER.md` on what that read does not guarantee). That governs research-phase edits; §7 Step 2 never writes the Task body at all. Ensure the parent Epic is `in-progress` when active work begins; the Task stays in `backlog` until its plan is approved (then `ready`, per step 7). ## 3. Resolve standards and deep research @@ -108,7 +108,7 @@ Not every spec ends in a plan. When research shows the premise is already delive Nothing here starts until both mandatory §3 passes have run — the `sy:debate` pass over the core decision and the `sy:spec-gate` review of the drafted plan — with every spec-gate finding already dispositioned. -The plan itself has two clearly labeled parts, so a human reviewer and a fresh `/sy:ship` session each get only what they need without wading through the other's: +The plan itself has two clearly labeled parts, so a human reviewer and a fresh `/sy:ship` session each get only what they need without wading through the other's. Both parts are drafted under `${CLAUDE_PLUGIN_ROOT}/skills/shared/references/context-economy.md`; read it before writing either. The rule that bites hardest here: the `/sy:ship` part is written to the implementer, so a decision this plan has already settled is stated for them to act on, never re-argued in the half they act from. **For your sign-off** (rationale and judgment calls): @@ -143,13 +143,13 @@ The ship profile never lowers review or build: `sy:gate` remains frontier tier a Present a short natural-prose summary: what you are going to do, why this way, the strongest alternative you rejected and why, the risks worth knowing, and what this deliberately excludes. A few paragraphs, read once and understood — no nested outline, no file inventory, no restatement of the `/sy:ship` section. What is being approved is the judgment; the mechanics exist for `/sy:ship`, and pasting them here buries the decision the user is being asked to make. -Then close the turn with a single `AskUserQuestion` call — approve as-is / request changes / other — per `${CLAUDE_PLUGIN_ROOT}/skills/shared/references/user-interaction.md`. Name the mutation the approval authorizes: on approval the run will post the full ACTIVE plan comment (and, when superseding, mark the prior plan SUPERSEDED), post this summary as a comment on the Task — Step 2 never writes the Task body (a body edit during research, §2, is governed separately by that section, not by this rule) — and set the Task `ready`. Under auto-mode this sign-off is the consent point for those writes, so it states them rather than implying them. This is the plan's sign-off gate: do not infer approval from a reply that doesn't answer it. +Then close the turn with a single `AskUserQuestion` call — approve as-is / request changes / other — per `${CLAUDE_PLUGIN_ROOT}/skills/shared/references/user-interaction.md`. Name the mutation the approval authorizes: on approval the run will post the full ACTIVE plan comment (and, when superseding, mark the prior plan SUPERSEDED) and set the Task `ready` — it does not touch the Task body (a body edit during research, §2, is governed separately by that section, not by this rule). Under auto-mode this sign-off is the consent point for those writes, so it states them rather than implying them. This is the plan's sign-off gate: do not infer approval from a reply that doesn't answer it. A `request changes` answer revises the draft and returns to this step; re-run `sy:spec-gate` only when that revision is material, per the re-dispatch rule in its reference. ### Step 2 — after approval, post the full plan -Both labeled parts are revealed here, in full, rather than at Step 1. Marking a superseded plan SUPERSEDED rather than leaving two ACTIVE is the retroactive-honesty invariant in `${CLAUDE_PLUGIN_ROOT}/skills/shared/references/write-integrity.md`: an overruled record is corrected on its own surface, never left standing. +Both labeled parts are revealed here, in full, rather than at Step 1. This step never writes the Task body — not for a pre-existing Task, and not even for one this run just created — so no existing body content is ever a target of this run's write. (A body edit during research, §2, is governed separately by that section, not by this rule.) Marking a superseded plan SUPERSEDED rather than leaving two ACTIVE is the retroactive-honesty invariant in `${CLAUDE_PLUGIN_ROOT}/skills/shared/references/write-integrity.md`: an overruled record is corrected on its own surface, never left standing. 1. if an older plan is ACTIVE, edit its comment to: @@ -168,8 +168,7 @@ Supersedes: v # omit for v1 ``` 3. verify by rereading plan headings/statuses that **exactly one** plan is ACTIVE. -4. post the Step-1 summary as a comment on the Task via the `tracker` skill (`post-comment`): `human` is the approach, the strongest rejected alternative, the risks, and the exclusions; `agent_detail` is the mutations the approval authorized. This step never writes the Task body — not for a pre-existing Task, and not even for one this run just created — so no existing body content is ever a target of this run's write. (A body edit during research, §2, is governed separately by that section, not by this rule.) -5. set the Task to `ready` via the `tracker` skill — the plan is approved and it is now shippable. +4. set the Task to `ready` via the `tracker` skill — the plan is approved and it is now shippable. The bar: a fresh session reading the Task and sole ACTIVE plan can implement and open the PR without missing design decisions. From f22919b234f1eb9f48e121c32824741e3e6f97d3 Mon Sep 17 00:00:00 2001 From: Brett Tully Date: Thu, 6 Aug 2026 22:05:52 +1000 Subject: [PATCH 02/14] AM-1321 - Refuse an oversized tracker body before the write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four writers that already share the machine-log gate now share a body-size gate too: a body past the selected adapter's limit is refused whole, naming the measured length, the limit and the overflow, so a caller knows how much to cut rather than retrying blind into the tracker's own rejection. The limit lives on the adapter because it differs per tracker and neither figure is firm — Jira's 32,767 is documented for comments only and in an undocumented unit under ADF, GitHub's 65,536 is attested by its API error string alone — so both are stated as best-effort with their provenance, in the protocol and in each ADAPTER.md. Co-Authored-By: Claude Opus 5 --- skills/tracker/CONTRACT.md | 2 + skills/tracker/github/ADAPTER.md | 2 + skills/tracker/jira/ADAPTER.md | 2 + sy_tools/server.py | 29 ++++++++++++-- sy_tools/tests/test_server.py | 62 ++++++++++++++++++++++++++++++ sy_tools/tracker/__init__.py | 20 ++++++++++ sy_tools/tracker/github/adapter.py | 3 ++ sy_tools/tracker/jira/adapter.py | 3 ++ 8 files changed, 119 insertions(+), 4 deletions(-) diff --git a/skills/tracker/CONTRACT.md b/skills/tracker/CONTRACT.md index a70a32b..8406f1e 100644 --- a/skills/tracker/CONTRACT.md +++ b/skills/tracker/CONTRACT.md @@ -119,6 +119,8 @@ Generate usage from the on-disk transcript tree with the `usage_summarize` tool `shipyard.ship_metrics.v1` is **enforced**, not just documented: a body naming that id must carry exactly one fenced JSON block whose top-level `schema` key claims it, and `post-log`, `post-comment`, `create-issue` and `update-issue` alike refuse the whole write when that block does not match the schema — the identical gate on all four bodies, so a machine log written into an issue body is held to exactly what a comment is — and equally when the body carries more than one such block, since which is the log is then ambiguous and validating the first would write the rest unchecked. That count is not limited to well-formed fences: an unclosed block, a closing marker with trailing text, or a bare prose mention of the id all count as a candidate too, so naming the id anywhere outside the one valid block is also a refusal, not a silent pass-through. A malformed metrics log therefore cannot land and then be read as authoritative. Field definitions live in exactly one place, `${CLAUDE_PLUGIN_ROOT}/skills/ship/references/handoff-accounting.md`; the executable copy is `sy_tools/ship_metrics.py`. Every field is optional except `schema` and `task` — an unknown metric is posted as `null`, never as a plausible zero — with the few exceptions the model states and the reference explains. A body that neither names the id — literally or via a JSON `\uXXXX` escape — nor carries a block whose parsed content resolves to it passes through unvalidated. +**Body size is the second such guard, over the identical four writers.** A body longer than the selected adapter's limit is refused whole by `post-log`, `post-comment`, `create-issue` and `update-issue` alike, before the write is attempted, with a message naming the measured length, the limit, and the overflow — the tracker would refuse an oversized body outright anyway, and nothing here truncates a body to fit, because a silently shortened plan or retrospective reads as a complete one. The limit is **per adapter and best-effort**, not a spec: each adapter states its own number and where that number comes from, and a body under it is one the tracker has not been observed to refuse rather than one it promises to accept. Split oversized content across writes, or shorten it. + ### Attachments may degrade to a link `attach-artifact` uploads a file where the tracker supports it (Jira work-item attachments). Where it does not (GitHub issues have no CLI-scriptable attachment), the adapter substitutes an equivalent durable artifact (a private gist) and links it from a comment. Either way the artifact is secret-scanned before it leaves the machine, and the log comment references it by name/URL. This asymmetry is documented per adapter and in the deliberate-asymmetries section of the README. The lifecycle verbs act on whatever the adapter created, so `attachment-download` on a GitHub issue reads the gist back. diff --git a/skills/tracker/github/ADAPTER.md b/skills/tracker/github/ADAPTER.md index bf4253c..a9eae8f 100644 --- a/skills/tracker/github/ADAPTER.md +++ b/skills/tracker/github/ADAPTER.md @@ -66,6 +66,8 @@ An issue's opaque id is its **URL** in everything this adapter returns. `gh` acc - **`link-pr`**: reference the issue from the PR body as a plain `#`, **not** a closing keyword — the done transition is owned by native project automation on merge, not by the PR text. - **`type-convert`** rewrites the board `Type` field on an existing issue, verified by the same bounded re-read every board write uses. +This adapter's body limit is **65,536 characters**, applied by the shared whole-write refusal in `../CONTRACT.md`. GitHub documents no such limit anywhere: the figure is attested by nothing but the API's own error string on a write that exceeds it, so treat it as observed behaviour that could move rather than a published bound. + ### `attach-artifact` and the attachment lifecycle — gist + link (deliberate asymmetry) GitHub issues have no CLI-scriptable file attachment, so the artifact is uploaded as a **secret** (private) gist and linked from a comment on the issue. Hand the rendered path to the `attach-artifact` tool: it checks the gate and runs both sanitisation passes — the same ones, in the same order, as on the Jira path — before creating the gist, and returns the gist URL as its evidence. The caller names no tracker; the asymmetry lives here, in the adapter. Privacy is verified by reading the created gist back rather than assumed from the flags passed: a public gist would publish a transcript irrevocably. diff --git a/skills/tracker/jira/ADAPTER.md b/skills/tracker/jira/ADAPTER.md index 43d63b8..e1af048 100644 --- a/skills/tracker/jira/ADAPTER.md +++ b/skills/tracker/jira/ADAPTER.md @@ -60,6 +60,8 @@ Everything below is Jira-specific behaviour a caller can rely on. Where a verb i - **`link-pr`**: PRs surface in the Jira development panel when the branch or commit names the issue key. The verb's durable half is a comment whose `human` notes that a PR now exists and whose `agent_detail` is the PR URL, so the association survives regardless of dev-panel wiring. - **`type-convert`** rewrites the work item's type in place and verifies by reading it back. Some site workflows restrict type changes (required fields, hierarchy rules); it then fails loudly rather than leaving the type silently unchanged. Irreversible side effects — parent links, board membership — follow the type. +This adapter's body limit is **32,767 characters**, applied by the shared whole-write refusal in `../CONTRACT.md`. Two things about that number matter before relying on it: it comes from JRACLOUD-63007 and the Jira Cloud KB, where the *unit* is left undocumented for an ADF body, and it is stated for **comments** only — holding `create-issue` and `update-issue` descriptions to the same figure is this adapter's deliberate assumption, not something Jira documents. + Deleting a dependency link is not a contract verb: no workflow drives it, so it stays a manual `acli jira workitem link delete --id --yes` outside Shipyard. ## `attach-artifact` and the attachment lifecycle diff --git a/sy_tools/server.py b/sy_tools/server.py index f9a9a25..f400886 100644 --- a/sy_tools/server.py +++ b/sy_tools/server.py @@ -131,7 +131,12 @@ async def create_issue( # back out of a tracker's own history. (title, body), scrub = _scrub_texts(title, body) _validate_machine_log(body) - created = await tracker.adapter().create_issue(issue_type=issue_type, title=title, body=body, parent=parent) + # Order is load-bearing at all four writers that run these two checks: the machine-log check must be + # able to refuse before `adapter()` is called at all, and the size limit is only readable off the + # adapter — so the lookup sits between them and is never hoisted above the first check. + adapter = tracker.adapter() + _validate_body_size(body, adapter.body_limit) + created = await adapter.create_issue(issue_type=issue_type, title=title, body=body, parent=parent) return {**created, "scrub": scrub} @@ -165,7 +170,9 @@ async def update_issue( _required(issue=issue) (body,), scrub = _scrub_texts(body) _validate_machine_log(body) - updated = await tracker.adapter().update_issue(issue, body) + adapter = tracker.adapter() + _validate_body_size(body, adapter.body_limit) + updated = await adapter.update_issue(issue, body) return {**updated, "scrub": scrub} @@ -326,7 +333,9 @@ async def post_comment( # Kept as a defensive backstop, not as routing: `post-log` assembles and validates its own body, and # what this catches here is a claim that is prose-only, malformed, or ambiguous — never a valid log. _validate_machine_log(body) - posted = await tracker.adapter().post_comment(issue, body) + adapter = tracker.adapter() + _validate_body_size(body, adapter.body_limit) + posted = await adapter.post_comment(issue, body) return {**posted, "scrub": scrub} @@ -364,7 +373,9 @@ async def post_log( (title, payload_json), scrub = _scrub_texts(title, payload_json) body = f"# {title.strip()}\n\n```json\n{payload_json}\n```\n" _validate_machine_log(body) - posted = await tracker.adapter().post_comment(issue, body) + adapter = tracker.adapter() + _validate_body_size(body, adapter.body_limit) + posted = await adapter.post_comment(issue, body) return {**posted, "scrub": scrub} @@ -475,6 +486,16 @@ def _claims_within(parsed: object) -> bool: return False +def _validate_body_size(body: str, limit: int) -> None: + if len(body) > limit: + raise ToolError( + f"this body is {len(body)} characters and the limit is {limit}, so it was refused: " + f"{len(body) - limit} over. The write was not attempted, because a tracker that refuses an " + "oversized body refuses it whole — nothing partial lands and nothing is truncated for you. " + "Split the content across writes, or shorten it and send the shorter body." + ) + + def _validate_machine_log(body: str) -> None: """Reject a malformed `shipyard.ship_metrics.v1` block before the body it sits in is written. diff --git a/sy_tools/tests/test_server.py b/sy_tools/tests/test_server.py index 65f2ea2..4e39d4d 100644 --- a/sy_tools/tests/test_server.py +++ b/sy_tools/tests/test_server.py @@ -97,6 +97,9 @@ class _Recorder: """ name = "recorder" + # A real attribute, not left to `__getattr__`: that returns an async callable for any name, so every + # write test below would compare a function to an int in the body-size check and raise TypeError. + body_limit = 32_767 def __init__(self) -> None: self.calls: list[tuple[str, tuple, dict]] = [] @@ -321,6 +324,65 @@ async def test_post_log_refuses_a_title_that_spans_lines_before_the_adapter_is_t assert result.is_error is True, f"a multi-line title was posted: {result.content}" +STUB_BODY_LIMIT = 400 +"""A small stand-in for the real per-adapter limit, so a case can be built out of a short string. + +Building a genuine 32,767-character body would pin this suite to one adapter's number and make every +case here re-derive it; the guard's behaviour is the same at any limit. +""" + +SIZE_WRITES = [ + ("create-issue", + lambda filler: {"issue_type": "task", "title": "T", "body": filler}, + lambda filler: filler), + ("update-issue", + lambda filler: {"issue": "PROJ-1", "body": filler}, + lambda filler: filler), + ("post-comment", + lambda filler: {"issue": "PROJ-1", "human": "TL;DR: sized.", "agent_detail": filler}, + lambda filler: "TL;DR: sized." + server._TWO_PART_SEPARATOR + filler), + ("post-log", + lambda filler: {"issue": "PROJ-1", "title": "Claude Code usage", "payload": {"note": filler}}, + lambda filler: f'# Claude Code usage\n\n```json\n{json.dumps({"note": filler}, indent=2)}\n```\n'), +] +"""Each writer the size guard covers, its arguments for a filler string, and the body it assembles. + +The second lambda is what makes `post-comment` and `post-log` testable at a boundary at all: neither +sends the string it was given, so a case sized against the argument would be sizing the wrong text. +""" + + +@pytest.mark.anyio +@pytest.mark.parametrize(("tool", "arguments", "assembled"), SIZE_WRITES, ids=[t for t, _a, _b in SIZE_WRITES]) +@pytest.mark.parametrize("overflow", [1, 0], ids=["one over the limit", "exactly at the limit"]) +async def test_a_body_over_the_adapters_limit_is_refused_whole_and_one_at_it_still_writes( + monkeypatch, tool, arguments, assembled, overflow +): + """Both boundaries, on every writer, because either half alone is a guard that looks like it works. + + Over the limit the tracker refuses the write outright, so the useful answer is a refusal here that + names the measured length — a caller with an oversized body has to know how much to cut, and a + length it can only guess at is what makes the retry a second failed write. At the limit is the half + that catches an off-by-one: a guard one character early silently costs every writer a character of + every body, and nothing else in this suite would notice. + """ + recorder = _Recorder() + recorder.body_limit = STUB_BODY_LIMIT + monkeypatch.setattr(server.tracker, "adapter", lambda: recorder) + filler = "x" * (STUB_BODY_LIMIT + overflow - len(assembled(""))) + body = assembled(filler) + assert len(body) == STUB_BODY_LIMIT + overflow, f"the case built {len(body)} characters, not the length it tests" + async with mcp.Client(server.mcp) as client: + result = await client.call_tool(tool, arguments(filler)) + if overflow: + assert result.is_error is True, f"{tool} wrote {len(body)} characters past a {STUB_BODY_LIMIT} limit" + assert str(len(body)) in _text(result), f"the refusal must name the measured length: {_text(result)}" + assert not recorder.calls, f"{tool} reached the adapter with an oversized body: {recorder.calls}" + else: + assert result.is_error is False, result.content + assert recorder.calls, f"{tool} refused a body that was exactly at the limit" + + @pytest.mark.anyio async def test_a_tracker_failure_comes_back_as_a_tool_result(monkeypatch): """No per-tool try/except: the SDK already turns a raising tool into an `isError` result. diff --git a/sy_tools/tracker/__init__.py b/sy_tools/tracker/__init__.py index 5ab49f3..c20c22e 100644 --- a/sy_tools/tracker/__init__.py +++ b/sy_tools/tracker/__init__.py @@ -117,6 +117,26 @@ async def add_label(self, issue: str, label: str) -> dict: """Add `label` to `issue`, preserving the labels already on it, and return the resulting set.""" ... + body_limit: int + """The largest body this tracker is believed to take, in characters. Best-effort, not a guarantee. + + Annotation only, with no value: a default here would put a non-verb into `vars()`, which is where + `sy_tools/tests/tracker/test_canonical.py` pins the canonical verb set. + + It guards issue descriptions as well as comments, which is why it is not `comment_body_limit`. + Neither adapter's figure is firm, and both are stated with their provenance rather than presented + as spec: + + - Jira's 32,767 comes from JRACLOUD-63007 and the Jira Cloud KB. The *unit* is undocumented under + ADF, and the figure is established for **comments only** — applying it to an issue description is + an unverified assumption that adapter makes deliberately. + - GitHub's 65,536 is undocumented entirely, attested by nothing but the API's own error string. + + So a body under the limit is one this tracker has not been observed to refuse, not one it promises + to accept. The point of the number is to turn the common overflow into a refusal a caller can act + on before the write, not to certify the boundary. + """ + async def post_comment(self, issue: str, body: str) -> dict: """Post `body` as a Markdown comment on `issue` and return the comment the write created.""" ... diff --git a/sy_tools/tracker/github/adapter.py b/sy_tools/tracker/github/adapter.py index acf56f7..4211dea 100644 --- a/sy_tools/tracker/github/adapter.py +++ b/sy_tools/tracker/github/adapter.py @@ -89,6 +89,9 @@ class GithubAdapter: """ name = "github" + # Undocumented: GitHub publishes no body limit anywhere, and this figure is attested by nothing but + # the API's own error string on a write that goes over it. + body_limit: int = 65_536 def __init__(self) -> None: self._boards: dict[str, dict[str, Any]] = {} diff --git a/sy_tools/tracker/jira/adapter.py b/sy_tools/tracker/jira/adapter.py index 7945a9b..25576be 100644 --- a/sy_tools/tracker/jira/adapter.py +++ b/sy_tools/tracker/jira/adapter.py @@ -97,6 +97,9 @@ class JiraAdapter: """The canonical tracker verbs, implemented against the Jira Cloud REST API.""" name = "jira" + # Characters, per JRACLOUD-63007 and the Jira Cloud KB. That figure is stated for comments only and + # its unit is undocumented under ADF; holding descriptions to it too is a deliberate assumption. + body_limit: int = 32_767 def __init__(self) -> None: self._account_id: str | None = None From 4c1d6f07e25f0f656e1bf6ed73128a0d99d82af6 Mon Sep 17 00:00:00 2001 From: Brett Tully Date: Thu, 6 Aug 2026 22:30:33 +1000 Subject: [PATCH 03/14] AM-1321 - Trim cross-part restatement and close a truncated quote Gate review AM-1321: drop the paraphrase of context-economy.md's write-to-the-actor rule from spec/SKILL.md sec7 (it duplicated the cited reference, violating single-copy/pointer-only); add an ellipsis to the truncated Chroma quote in context-economy.md. --- skills/shared/references/context-economy.md | 2 +- skills/spec/SKILL.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/shared/references/context-economy.md b/skills/shared/references/context-economy.md index 61816ea..faa70bd 100644 --- a/skills/shared/references/context-economy.md +++ b/skills/shared/references/context-economy.md @@ -2,7 +2,7 @@ Every agent-facing artifact Shipyard writes is loaded into a finite budget and read by a model that gets worse at using it the fuller it gets. Anthropic's guidance names the resource directly — "LLMs have an 'attention budget' that they draw on when parsing large volumes of context" — and sets the target as finding "the smallest possible set of high-signal tokens that maximize the likelihood of some desired outcome" (https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents). This file is the single copy of that rule for Shipyard's own artifacts: skills, agent briefs, plans, roadmaps, PR descriptions, state briefs, handoff records. Cite it from wherever an artifact gets authored; never restate it there. -The cost is measured, not stylistic. Chroma's *Context Rot* evaluated 18 LLMs across 194,480 calls and found that "even under these minimal conditions, model performance degrades as input length increases" — on tasks trivial enough that length was the only variable (https://www.trychroma.com/research/context-rot). Position compounds volume: performance "is often highest when relevant information occurs at the beginning or end of the input context, and significantly degrades when models must access relevant information in the middle" (Liu et al., https://arxiv.org/abs/2307.03172). And instructions dilute each other — IFScale reports that "even the best frontier models only achieve 68% accuracy at the max density of 500 instructions", with later instructions dropped more often than earlier ones (https://arxiv.org/abs/2507.11538). A sentence added to a brief is not free: it competes with every other sentence there, and it pushes something else toward the middle. +The cost is measured, not stylistic. Chroma's *Context Rot* evaluated 18 LLMs across 194,480 calls and found that "even under these minimal conditions, model performance degrades as input length increases…" — on tasks trivial enough that length was the only variable (https://www.trychroma.com/research/context-rot). Position compounds volume: performance "is often highest when relevant information occurs at the beginning or end of the input context, and significantly degrades when models must access relevant information in the middle" (Liu et al., https://arxiv.org/abs/2307.03172). And instructions dilute each other — IFScale reports that "even the best frontier models only achieve 68% accuracy at the max density of 500 instructions", with later instructions dropped more often than earlier ones (https://arxiv.org/abs/2507.11538). A sentence added to a brief is not free: it competes with every other sentence there, and it pushes something else toward the middle. ## The two cut tests diff --git a/skills/spec/SKILL.md b/skills/spec/SKILL.md index 6e32512..0d7dcf1 100644 --- a/skills/spec/SKILL.md +++ b/skills/spec/SKILL.md @@ -108,7 +108,7 @@ Not every spec ends in a plan. When research shows the premise is already delive Nothing here starts until both mandatory §3 passes have run — the `sy:debate` pass over the core decision and the `sy:spec-gate` review of the drafted plan — with every spec-gate finding already dispositioned. -The plan itself has two clearly labeled parts, so a human reviewer and a fresh `/sy:ship` session each get only what they need without wading through the other's. Both parts are drafted under `${CLAUDE_PLUGIN_ROOT}/skills/shared/references/context-economy.md`; read it before writing either. The rule that bites hardest here: the `/sy:ship` part is written to the implementer, so a decision this plan has already settled is stated for them to act on, never re-argued in the half they act from. +The plan itself has two clearly labeled parts, so a human reviewer and a fresh `/sy:ship` session each get only what they need without wading through the other's. Both parts are drafted under `${CLAUDE_PLUGIN_ROOT}/skills/shared/references/context-economy.md`; read it before writing either. **For your sign-off** (rationale and judgment calls): From 85348f673e923e344e03fabb226aad44affd46ea Mon Sep 17 00:00:00 2001 From: Brett Tully Date: Thu, 6 Aug 2026 22:58:58 +1000 Subject: [PATCH 04/14] AM-1321 - Correct two sourcing defects in the shipped documentation Handoff doc-accuracy check found two real sourcing defects: - context-economy.md's Liu et al. quote closed as complete but the source sentence continues with a material qualifier ('of long contexts, even for explicitly long-context models'); extend the quote rather than truncate it. - the Jira body_limit provenance claimed the 32,767 figure is established for comments only, applying it to descriptions as an unverified assumption. Atlassian documents the limit for both via the site-wide jira.text.field.character.limit property (JRACLOUD-59124, JRACLOUD-68949); only the ADF unit stays undocumented. Corrected at all four sites: the shared body_limit docstring, the Jira adapter's body_limit comment, and skills/tracker/jira/ADAPTER.md. --- skills/shared/references/context-economy.md | 2 +- skills/tracker/jira/ADAPTER.md | 2 +- sy_tools/tracker/__init__.py | 8 +++++--- sy_tools/tracker/jira/adapter.py | 5 +++-- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/skills/shared/references/context-economy.md b/skills/shared/references/context-economy.md index faa70bd..83ca0d5 100644 --- a/skills/shared/references/context-economy.md +++ b/skills/shared/references/context-economy.md @@ -2,7 +2,7 @@ Every agent-facing artifact Shipyard writes is loaded into a finite budget and read by a model that gets worse at using it the fuller it gets. Anthropic's guidance names the resource directly — "LLMs have an 'attention budget' that they draw on when parsing large volumes of context" — and sets the target as finding "the smallest possible set of high-signal tokens that maximize the likelihood of some desired outcome" (https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents). This file is the single copy of that rule for Shipyard's own artifacts: skills, agent briefs, plans, roadmaps, PR descriptions, state briefs, handoff records. Cite it from wherever an artifact gets authored; never restate it there. -The cost is measured, not stylistic. Chroma's *Context Rot* evaluated 18 LLMs across 194,480 calls and found that "even under these minimal conditions, model performance degrades as input length increases…" — on tasks trivial enough that length was the only variable (https://www.trychroma.com/research/context-rot). Position compounds volume: performance "is often highest when relevant information occurs at the beginning or end of the input context, and significantly degrades when models must access relevant information in the middle" (Liu et al., https://arxiv.org/abs/2307.03172). And instructions dilute each other — IFScale reports that "even the best frontier models only achieve 68% accuracy at the max density of 500 instructions", with later instructions dropped more often than earlier ones (https://arxiv.org/abs/2507.11538). A sentence added to a brief is not free: it competes with every other sentence there, and it pushes something else toward the middle. +The cost is measured, not stylistic. Chroma's *Context Rot* evaluated 18 LLMs across 194,480 calls and found that "even under these minimal conditions, model performance degrades as input length increases…" — on tasks trivial enough that length was the only variable (https://www.trychroma.com/research/context-rot). Position compounds volume: performance "is often highest when relevant information occurs at the beginning or end of the input context, and significantly degrades when models must access relevant information in the middle of long contexts, even for explicitly long-context models" (Liu et al., https://arxiv.org/abs/2307.03172). And instructions dilute each other — IFScale reports that "even the best frontier models only achieve 68% accuracy at the max density of 500 instructions", with later instructions dropped more often than earlier ones (https://arxiv.org/abs/2507.11538). A sentence added to a brief is not free: it competes with every other sentence there, and it pushes something else toward the middle. ## The two cut tests diff --git a/skills/tracker/jira/ADAPTER.md b/skills/tracker/jira/ADAPTER.md index e1af048..95f8fdf 100644 --- a/skills/tracker/jira/ADAPTER.md +++ b/skills/tracker/jira/ADAPTER.md @@ -60,7 +60,7 @@ Everything below is Jira-specific behaviour a caller can rely on. Where a verb i - **`link-pr`**: PRs surface in the Jira development panel when the branch or commit names the issue key. The verb's durable half is a comment whose `human` notes that a PR now exists and whose `agent_detail` is the PR URL, so the association survives regardless of dev-panel wiring. - **`type-convert`** rewrites the work item's type in place and verifies by reading it back. Some site workflows restrict type changes (required fields, hierarchy rules); it then fails loudly rather than leaving the type silently unchanged. Irreversible side effects — parent links, board membership — follow the type. -This adapter's body limit is **32,767 characters**, applied by the shared whole-write refusal in `../CONTRACT.md`. Two things about that number matter before relying on it: it comes from JRACLOUD-63007 and the Jira Cloud KB, where the *unit* is left undocumented for an ADF body, and it is stated for **comments** only — holding `create-issue` and `update-issue` descriptions to the same figure is this adapter's deliberate assumption, not something Jira documents. +This adapter's body limit is **32,767 characters**, applied by the shared whole-write refusal in `../CONTRACT.md`. It comes from JRACLOUD-63007 and the Jira Cloud KB, and is governed by the site-wide `jira.text.field.character.limit` property, which Atlassian documents for both descriptions and comments (JRACLOUD-59124, JRACLOUD-68949) — so holding `create-issue` and `update-issue` descriptions to the same figure as comments is documented, not assumed. What is still left undocumented is the *unit* for an ADF body, which is why the limit stays best-effort. Deleting a dependency link is not a contract verb: no workflow drives it, so it stays a manual `acli jira workitem link delete --id --yes` outside Shipyard. diff --git a/sy_tools/tracker/__init__.py b/sy_tools/tracker/__init__.py index c20c22e..e5c4987 100644 --- a/sy_tools/tracker/__init__.py +++ b/sy_tools/tracker/__init__.py @@ -127,9 +127,11 @@ async def add_label(self, issue: str, label: str) -> dict: Neither adapter's figure is firm, and both are stated with their provenance rather than presented as spec: - - Jira's 32,767 comes from JRACLOUD-63007 and the Jira Cloud KB. The *unit* is undocumented under - ADF, and the figure is established for **comments only** — applying it to an issue description is - an unverified assumption that adapter makes deliberately. + - Jira's 32,767 comes from JRACLOUD-63007 and the Jira Cloud KB, and is governed by the site-wide + `jira.text.field.character.limit` property, which Atlassian documents for both descriptions and + comments (JRACLOUD-59124, JRACLOUD-68949) — not comments only. The *unit* is still undocumented + under ADF, so the figure remains best-effort even though its coverage of descriptions is + documented, not assumed. - GitHub's 65,536 is undocumented entirely, attested by nothing but the API's own error string. So a body under the limit is one this tracker has not been observed to refuse, not one it promises diff --git a/sy_tools/tracker/jira/adapter.py b/sy_tools/tracker/jira/adapter.py index 25576be..6ea5bcd 100644 --- a/sy_tools/tracker/jira/adapter.py +++ b/sy_tools/tracker/jira/adapter.py @@ -97,8 +97,9 @@ class JiraAdapter: """The canonical tracker verbs, implemented against the Jira Cloud REST API.""" name = "jira" - # Characters, per JRACLOUD-63007 and the Jira Cloud KB. That figure is stated for comments only and - # its unit is undocumented under ADF; holding descriptions to it too is a deliberate assumption. + # Characters, per JRACLOUD-63007 and the Jira Cloud KB, governed by the site-wide + # jira.text.field.character.limit property and documented for both descriptions and comments + # (JRACLOUD-59124, JRACLOUD-68949); its unit is still undocumented under ADF. body_limit: int = 32_767 def __init__(self) -> None: From 08514d541e2217d2b3ee976f7daa6e01437ae1b9 Mon Sep 17 00:00:00 2001 From: Brett Tully Date: Thu, 6 Aug 2026 23:10:23 +1000 Subject: [PATCH 05/14] AM-1321 - Fix a citation mis-mapping in the Jira body-limit provenance The prior sourcing fix cited JRACLOUD-59124 (a low-vote, property-silent feature suggestion) and JRACLOUD-68949 (a Won't-Fix report, not an Atlassian doc) as evidence the jira.text.field.character.limit property is Atlassian-documented for Cloud. Neither ticket names the property, and the property is only exposed for tuning in Data Center per JRACLOUD-63007's own resolution. Reworded to attribute the documented per-text-field validation to the Jira Cloud KB article, and demote the two tickets to what they actually attest: Cloud customers observing the same 32,767 figure on both description and comment fields. --- skills/tracker/jira/ADAPTER.md | 2 +- sy_tools/tracker/__init__.py | 11 ++++++----- sy_tools/tracker/jira/adapter.py | 7 ++++--- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/skills/tracker/jira/ADAPTER.md b/skills/tracker/jira/ADAPTER.md index 95f8fdf..0333e12 100644 --- a/skills/tracker/jira/ADAPTER.md +++ b/skills/tracker/jira/ADAPTER.md @@ -60,7 +60,7 @@ Everything below is Jira-specific behaviour a caller can rely on. Where a verb i - **`link-pr`**: PRs surface in the Jira development panel when the branch or commit names the issue key. The verb's durable half is a comment whose `human` notes that a PR now exists and whose `agent_detail` is the PR URL, so the association survives regardless of dev-panel wiring. - **`type-convert`** rewrites the work item's type in place and verifies by reading it back. Some site workflows restrict type changes (required fields, hierarchy rules); it then fails loudly rather than leaving the type silently unchanged. Irreversible side effects — parent links, board membership — follow the type. -This adapter's body limit is **32,767 characters**, applied by the shared whole-write refusal in `../CONTRACT.md`. It comes from JRACLOUD-63007 and the Jira Cloud KB, and is governed by the site-wide `jira.text.field.character.limit` property, which Atlassian documents for both descriptions and comments (JRACLOUD-59124, JRACLOUD-68949) — so holding `create-issue` and `update-issue` descriptions to the same figure as comments is documented, not assumed. What is still left undocumented is the *unit* for an ADF body, which is why the limit stays best-effort. +This adapter's body limit is **32,767 characters**, applied by the shared whole-write refusal in `../CONTRACT.md`. The Jira Cloud KB documents this as a text-field validation (`jira.text.field.character.limit`, default 32,767) that covers descriptions as well as comments; Cloud tickets JRACLOUD-63007 and JRACLOUD-68949 corroborate the same figure on both fields, though the property itself is exposed for tuning in Data Center only. What is still left undocumented is the *unit* for an ADF body, which is why the limit stays best-effort. Deleting a dependency link is not a contract verb: no workflow drives it, so it stays a manual `acli jira workitem link delete --id --yes` outside Shipyard. diff --git a/sy_tools/tracker/__init__.py b/sy_tools/tracker/__init__.py index e5c4987..bfed4f4 100644 --- a/sy_tools/tracker/__init__.py +++ b/sy_tools/tracker/__init__.py @@ -127,11 +127,12 @@ async def add_label(self, issue: str, label: str) -> dict: Neither adapter's figure is firm, and both are stated with their provenance rather than presented as spec: - - Jira's 32,767 comes from JRACLOUD-63007 and the Jira Cloud KB, and is governed by the site-wide - `jira.text.field.character.limit` property, which Atlassian documents for both descriptions and - comments (JRACLOUD-59124, JRACLOUD-68949) — not comments only. The *unit* is still undocumented - under ADF, so the figure remains best-effort even though its coverage of descriptions is - documented, not assumed. + - Jira's 32,767 comes from the Jira Cloud KB, which documents Jira validating any System or Custom + text field — descriptions included — against a `jira.text.field.character.limit` default of + 32,767; Cloud customers report the same figure on both fields (JRACLOUD-63007, JRACLOUD-68949). + That property is exposed for tuning in Data Center only — Cloud keeps the default without a UI to + change it — and the *unit* is still undocumented under ADF, so the figure remains best-effort even + though its coverage of descriptions is corroborated, not merely assumed. - GitHub's 65,536 is undocumented entirely, attested by nothing but the API's own error string. So a body under the limit is one this tracker has not been observed to refuse, not one it promises diff --git a/sy_tools/tracker/jira/adapter.py b/sy_tools/tracker/jira/adapter.py index 6ea5bcd..33b76b2 100644 --- a/sy_tools/tracker/jira/adapter.py +++ b/sy_tools/tracker/jira/adapter.py @@ -97,9 +97,10 @@ class JiraAdapter: """The canonical tracker verbs, implemented against the Jira Cloud REST API.""" name = "jira" - # Characters, per JRACLOUD-63007 and the Jira Cloud KB, governed by the site-wide - # jira.text.field.character.limit property and documented for both descriptions and comments - # (JRACLOUD-59124, JRACLOUD-68949); its unit is still undocumented under ADF. + # Characters. The Jira Cloud KB documents this text-field validation + # (jira.text.field.character.limit, default 32767) covering descriptions as well as comments; + # Cloud tickets JRACLOUD-63007/68949 corroborate the same figure on both fields, though the + # property itself is tunable only in Data Center. Its unit is still undocumented under ADF. body_limit: int = 32_767 def __init__(self) -> None: From 67a03c9d362aa90574a04beeba52ffa7cb18efaa Mon Sep 17 00:00:00 2001 From: Brett Tully Date: Thu, 6 Aug 2026 23:27:08 +1000 Subject: [PATCH 06/14] AM-1321 - Re-attribute the Jira body-limit citation to a Cloud-scoped source The previous round's fix cited a KB article stamped 'Data Center only' (never mentioning comments) as documenting the both-fields Cloud coverage, and cited JRACLOUD-63007 as attesting a field it never names. Fetched Atlassian's JCMA migration KB (platform: Cloud and Data Center), which states verbatim that on Cloud 'it's not possible to bypass the 32,767 character limit for both description and comments' and itself cites JRACLOUD-59124. Reattributed: the both-fields Cloud claim to that KB; the property's Data-Center-only tunability and the Cloud-default confirmation to JRACLOUD-63007; the description-specific corroboration to JRACLOUD-68949. --- skills/tracker/jira/ADAPTER.md | 2 +- sy_tools/tracker/__init__.py | 13 +++++++------ sy_tools/tracker/jira/adapter.py | 8 ++++---- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/skills/tracker/jira/ADAPTER.md b/skills/tracker/jira/ADAPTER.md index 0333e12..691b6cb 100644 --- a/skills/tracker/jira/ADAPTER.md +++ b/skills/tracker/jira/ADAPTER.md @@ -60,7 +60,7 @@ Everything below is Jira-specific behaviour a caller can rely on. Where a verb i - **`link-pr`**: PRs surface in the Jira development panel when the branch or commit names the issue key. The verb's durable half is a comment whose `human` notes that a PR now exists and whose `agent_detail` is the PR URL, so the association survives regardless of dev-panel wiring. - **`type-convert`** rewrites the work item's type in place and verifies by reading it back. Some site workflows restrict type changes (required fields, hierarchy rules); it then fails loudly rather than leaving the type silently unchanged. Irreversible side effects — parent links, board membership — follow the type. -This adapter's body limit is **32,767 characters**, applied by the shared whole-write refusal in `../CONTRACT.md`. The Jira Cloud KB documents this as a text-field validation (`jira.text.field.character.limit`, default 32,767) that covers descriptions as well as comments; Cloud tickets JRACLOUD-63007 and JRACLOUD-68949 corroborate the same figure on both fields, though the property itself is exposed for tuning in Data Center only. What is still left undocumented is the *unit* for an ADF body, which is why the limit stays best-effort. +This adapter's body limit is **32,767 characters**, applied by the shared whole-write refusal in `../CONTRACT.md`. Atlassian's own JCMA migration KB states that on Cloud "it's not possible to bypass the 32,767 character limit for both description and comments" (citing JRACLOUD-59124); the `jira.text.field.character.limit` property behind it is documented and admin-tunable in Data Center only — JRACLOUD-63007 is Atlassian declining to expose it in Cloud while confirming the same default applies there, and JRACLOUD-68949 corroborates the description-field limit specifically. What is still left undocumented is the *unit* for an ADF body, which is why the limit stays best-effort. Deleting a dependency link is not a contract verb: no workflow drives it, so it stays a manual `acli jira workitem link delete --id --yes` outside Shipyard. diff --git a/sy_tools/tracker/__init__.py b/sy_tools/tracker/__init__.py index bfed4f4..e5c61c5 100644 --- a/sy_tools/tracker/__init__.py +++ b/sy_tools/tracker/__init__.py @@ -127,12 +127,13 @@ async def add_label(self, issue: str, label: str) -> dict: Neither adapter's figure is firm, and both are stated with their provenance rather than presented as spec: - - Jira's 32,767 comes from the Jira Cloud KB, which documents Jira validating any System or Custom - text field — descriptions included — against a `jira.text.field.character.limit` default of - 32,767; Cloud customers report the same figure on both fields (JRACLOUD-63007, JRACLOUD-68949). - That property is exposed for tuning in Data Center only — Cloud keeps the default without a UI to - change it — and the *unit* is still undocumented under ADF, so the figure remains best-effort even - though its coverage of descriptions is corroborated, not merely assumed. + - Jira's 32,767 comes from Atlassian's own JCMA migration KB, which states that on Cloud "it's not + possible to bypass the 32,767 character limit for both description and comments" (citing + JRACLOUD-59124). The property behind it, `jira.text.field.character.limit`, is documented and + admin-tunable in Data Center only; JRACLOUD-63007 is Atlassian declining to expose it in Cloud + while confirming the same default applies there, and JRACLOUD-68949 corroborates the + description-field limit specifically. The *unit* is still undocumented under ADF, so the figure + remains best-effort even though its coverage of descriptions is documented, not merely assumed. - GitHub's 65,536 is undocumented entirely, attested by nothing but the API's own error string. So a body under the limit is one this tracker has not been observed to refuse, not one it promises diff --git a/sy_tools/tracker/jira/adapter.py b/sy_tools/tracker/jira/adapter.py index 33b76b2..76e8bb5 100644 --- a/sy_tools/tracker/jira/adapter.py +++ b/sy_tools/tracker/jira/adapter.py @@ -97,10 +97,10 @@ class JiraAdapter: """The canonical tracker verbs, implemented against the Jira Cloud REST API.""" name = "jira" - # Characters. The Jira Cloud KB documents this text-field validation - # (jira.text.field.character.limit, default 32767) covering descriptions as well as comments; - # Cloud tickets JRACLOUD-63007/68949 corroborate the same figure on both fields, though the - # property itself is tunable only in Data Center. Its unit is still undocumented under ADF. + # Characters. Atlassian's JCMA migration KB states the 32767 limit applies to both description and + # comments on Cloud (citing JRACLOUD-59124); the jira.text.field.character.limit property behind it + # is tunable in Data Center only (JRACLOUD-63007, JRACLOUD-68949). Its unit is still undocumented + # under ADF. body_limit: int = 32_767 def __init__(self) -> None: From 91d9dfe588f7f95ca2905801d8ff4e9b482ef5fe Mon Sep 17 00:00:00 2001 From: Brett Tully Date: Thu, 6 Aug 2026 23:38:38 +1000 Subject: [PATCH 07/14] AM-1321 - Fix a residual citation mis-binding and a voicing overclaim adapter.py:102 still bound JRACLOUD-68949 (a description-UI-hint Won't-Fix report) to a Data-Center-tunability claim it never attests, inconsistent with the two prose sites that already split the roles correctly. Also softened 'while confirming the same default applies there' to 'without disputing the reporter's premise that' at both prose sites: Atlassian's 2023 panel response never states the Cloud default figure itself, only declines to expose the property, so 'confirming' overclaimed what the source says. --- skills/tracker/jira/ADAPTER.md | 2 +- sy_tools/tracker/__init__.py | 3 ++- sy_tools/tracker/jira/adapter.py | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/skills/tracker/jira/ADAPTER.md b/skills/tracker/jira/ADAPTER.md index 691b6cb..d0295b8 100644 --- a/skills/tracker/jira/ADAPTER.md +++ b/skills/tracker/jira/ADAPTER.md @@ -60,7 +60,7 @@ Everything below is Jira-specific behaviour a caller can rely on. Where a verb i - **`link-pr`**: PRs surface in the Jira development panel when the branch or commit names the issue key. The verb's durable half is a comment whose `human` notes that a PR now exists and whose `agent_detail` is the PR URL, so the association survives regardless of dev-panel wiring. - **`type-convert`** rewrites the work item's type in place and verifies by reading it back. Some site workflows restrict type changes (required fields, hierarchy rules); it then fails loudly rather than leaving the type silently unchanged. Irreversible side effects — parent links, board membership — follow the type. -This adapter's body limit is **32,767 characters**, applied by the shared whole-write refusal in `../CONTRACT.md`. Atlassian's own JCMA migration KB states that on Cloud "it's not possible to bypass the 32,767 character limit for both description and comments" (citing JRACLOUD-59124); the `jira.text.field.character.limit` property behind it is documented and admin-tunable in Data Center only — JRACLOUD-63007 is Atlassian declining to expose it in Cloud while confirming the same default applies there, and JRACLOUD-68949 corroborates the description-field limit specifically. What is still left undocumented is the *unit* for an ADF body, which is why the limit stays best-effort. +This adapter's body limit is **32,767 characters**, applied by the shared whole-write refusal in `../CONTRACT.md`. Atlassian's own JCMA migration KB states that on Cloud "it's not possible to bypass the 32,767 character limit for both description and comments" (citing JRACLOUD-59124); the `jira.text.field.character.limit` property behind it is documented and admin-tunable in Data Center only — JRACLOUD-63007 is Atlassian declining to expose it in Cloud without disputing the reporter's premise that the same default applies there, and JRACLOUD-68949 corroborates the description-field limit specifically. What is still left undocumented is the *unit* for an ADF body, which is why the limit stays best-effort. Deleting a dependency link is not a contract verb: no workflow drives it, so it stays a manual `acli jira workitem link delete --id --yes` outside Shipyard. diff --git a/sy_tools/tracker/__init__.py b/sy_tools/tracker/__init__.py index e5c61c5..4564562 100644 --- a/sy_tools/tracker/__init__.py +++ b/sy_tools/tracker/__init__.py @@ -131,7 +131,8 @@ async def add_label(self, issue: str, label: str) -> dict: possible to bypass the 32,767 character limit for both description and comments" (citing JRACLOUD-59124). The property behind it, `jira.text.field.character.limit`, is documented and admin-tunable in Data Center only; JRACLOUD-63007 is Atlassian declining to expose it in Cloud - while confirming the same default applies there, and JRACLOUD-68949 corroborates the + without disputing the reporter's premise that the same default applies there, and JRACLOUD-68949 + corroborates the description-field limit specifically. The *unit* is still undocumented under ADF, so the figure remains best-effort even though its coverage of descriptions is documented, not merely assumed. - GitHub's 65,536 is undocumented entirely, attested by nothing but the API's own error string. diff --git a/sy_tools/tracker/jira/adapter.py b/sy_tools/tracker/jira/adapter.py index 76e8bb5..cf10473 100644 --- a/sy_tools/tracker/jira/adapter.py +++ b/sy_tools/tracker/jira/adapter.py @@ -99,8 +99,8 @@ class JiraAdapter: name = "jira" # Characters. Atlassian's JCMA migration KB states the 32767 limit applies to both description and # comments on Cloud (citing JRACLOUD-59124); the jira.text.field.character.limit property behind it - # is tunable in Data Center only (JRACLOUD-63007, JRACLOUD-68949). Its unit is still undocumented - # under ADF. + # is tunable in Data Center only (JRACLOUD-63007); JRACLOUD-68949 corroborates the + # description-field limit. Its unit is still undocumented under ADF. body_limit: int = 32_767 def __init__(self) -> None: From 7fe13ef2a3ef377cca44e0d76db2286d074fd12c Mon Sep 17 00:00:00 2001 From: Brett Tully Date: Thu, 6 Aug 2026 23:47:13 +1000 Subject: [PATCH 08/14] AM-1321 - Rewrap the body_limit docstring paragraph Cosmetic-only: the prior edit left a 22-char mid-sentence orphan line. No wording change. --- sy_tools/tracker/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sy_tools/tracker/__init__.py b/sy_tools/tracker/__init__.py index 4564562..f384347 100644 --- a/sy_tools/tracker/__init__.py +++ b/sy_tools/tracker/__init__.py @@ -131,10 +131,10 @@ async def add_label(self, issue: str, label: str) -> dict: possible to bypass the 32,767 character limit for both description and comments" (citing JRACLOUD-59124). The property behind it, `jira.text.field.character.limit`, is documented and admin-tunable in Data Center only; JRACLOUD-63007 is Atlassian declining to expose it in Cloud - without disputing the reporter's premise that the same default applies there, and JRACLOUD-68949 - corroborates the - description-field limit specifically. The *unit* is still undocumented under ADF, so the figure - remains best-effort even though its coverage of descriptions is documented, not merely assumed. + without disputing the reporter's premise that the same default applies there, and + JRACLOUD-68949 corroborates the description-field limit specifically. The *unit* is still + undocumented under ADF, so the figure remains best-effort even though its coverage of + descriptions is documented, not merely assumed. - GitHub's 65,536 is undocumented entirely, attested by nothing but the API's own error string. So a body under the limit is one this tracker has not been observed to refuse, not one it promises From 8ad23402c20405d3ef18e6223c8a398d0e69bdf4 Mon Sep 17 00:00:00 2001 From: Brett Tully Date: Fri, 7 Aug 2026 09:29:03 +1000 Subject: [PATCH 09/14] AM-1321 - Bind each body_limit constant to its ADAPTER.md figure The limit lived in three unbound copies per adapter (constant, ADAPTER.md prose, Protocol docstring), so editing the constant left two agent-facing docs asserting the old number with every check green. `check_invariants` now reads each adapter's declared `body_limit` out of source and requires the matching ADAPTER.md to state it, in either the bare or comma-grouped spelling. Also tightens two overclaims in the same area: refusing an oversized body is not proof the tracker would have refused it (Shipyard measures the Markdown it sends, not the converted body the tracker counts), and the limit's unit is characters of that Markdown body. Co-Authored-By: Claude Opus 5 --- scripts/validate.py | 20 ++++++++++++++++++++ skills/tracker/CONTRACT.md | 2 +- sy_tools/tracker/__init__.py | 3 ++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/scripts/validate.py b/scripts/validate.py index e31590e..d02edd8 100755 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -496,6 +496,16 @@ def check_invariants(errors: list[str]) -> None: def read(rel: str) -> str: return (ROOT / rel).read_text(encoding="utf-8") + # Read out of the source rather than imported: `python scripts/validate.py` puts `scripts/` on + # `sys.path`, not the repo root, so importing the adapters needs a `sys.path` shim this file has + # never needed for anything else. + def declared_body_limit(rel: str) -> int: + match = re.search(r"^ {4}body_limit: int = ([\d_]+)$", read(rel), re.MULTILINE) + if match is None: + fail(f"{rel} must declare `body_limit: int = `; its ADAPTER.md figure cannot be checked", errors) + return 0 + return int(match.group(1)) + ship = read("skills/ship/SKILL.md") handoff = read("skills/ship/references/handoff-accounting.md") merge = read("skills/ship/references/merge-accounting.md") @@ -788,6 +798,16 @@ def read(rel: str) -> str: if economy_axis_phrase in spec: fail("spec restates the prose-economy trigger; cite spec-gate.md instead of copying it", errors) + # Each adapter's body limit lives in the constant and again in its agent-facing ADAPTER.md prose. + # Both spellings count because the prose groups thousands and the code does not. + for name, source, doc_rel, doc in ( + ("jira", "sy_tools/tracker/jira/adapter.py", "skills/tracker/jira/ADAPTER.md", jira_adapter), + ("github", "sy_tools/tracker/github/adapter.py", "skills/tracker/github/ADAPTER.md", github_adapter), + ): + limit = declared_body_limit(source) + if str(limit) not in doc and f"{limit:,}" not in doc: + fail(f"{name} adapter's body_limit is {limit} ({source}); {doc_rel} states a different figure", errors) + # `post-comment` takes `human` and `agent_detail`, both required, and assembles the boundary itself. # These are the highest-traffic call sites, so a reference still describing one hand-composed body # would have every future session write the shape the tool now refuses. The lower-traffic sites diff --git a/skills/tracker/CONTRACT.md b/skills/tracker/CONTRACT.md index 8406f1e..aca4d84 100644 --- a/skills/tracker/CONTRACT.md +++ b/skills/tracker/CONTRACT.md @@ -119,7 +119,7 @@ Generate usage from the on-disk transcript tree with the `usage_summarize` tool `shipyard.ship_metrics.v1` is **enforced**, not just documented: a body naming that id must carry exactly one fenced JSON block whose top-level `schema` key claims it, and `post-log`, `post-comment`, `create-issue` and `update-issue` alike refuse the whole write when that block does not match the schema — the identical gate on all four bodies, so a machine log written into an issue body is held to exactly what a comment is — and equally when the body carries more than one such block, since which is the log is then ambiguous and validating the first would write the rest unchecked. That count is not limited to well-formed fences: an unclosed block, a closing marker with trailing text, or a bare prose mention of the id all count as a candidate too, so naming the id anywhere outside the one valid block is also a refusal, not a silent pass-through. A malformed metrics log therefore cannot land and then be read as authoritative. Field definitions live in exactly one place, `${CLAUDE_PLUGIN_ROOT}/skills/ship/references/handoff-accounting.md`; the executable copy is `sy_tools/ship_metrics.py`. Every field is optional except `schema` and `task` — an unknown metric is posted as `null`, never as a plausible zero — with the few exceptions the model states and the reference explains. A body that neither names the id — literally or via a JSON `\uXXXX` escape — nor carries a block whose parsed content resolves to it passes through unvalidated. -**Body size is the second such guard, over the identical four writers.** A body longer than the selected adapter's limit is refused whole by `post-log`, `post-comment`, `create-issue` and `update-issue` alike, before the write is attempted, with a message naming the measured length, the limit, and the overflow — the tracker would refuse an oversized body outright anyway, and nothing here truncates a body to fit, because a silently shortened plan or retrospective reads as a complete one. The limit is **per adapter and best-effort**, not a spec: each adapter states its own number and where that number comes from, and a body under it is one the tracker has not been observed to refuse rather than one it promises to accept. Split oversized content across writes, or shorten it. +**Body size is the second such guard, over the identical four writers.** A body longer than the selected adapter's limit is refused whole by `post-log`, `post-comment`, `create-issue` and `update-issue` alike, before the write is attempted, with a message naming the measured length, the limit, and the overflow — the tracker would refuse an oversized body outright anyway, though a refusal here is not proof it would have, since Shipyard measures the Markdown it sends rather than the converted body the tracker counts — and nothing here truncates a body to fit, because a silently shortened plan or retrospective reads as a complete one. The limit is **per adapter and best-effort**, not a spec: each adapter states its own number and where that number comes from, and a body under it is one the tracker has not been observed to refuse rather than one it promises to accept. Split oversized content across writes, or shorten it. ### Attachments may degrade to a link diff --git a/sy_tools/tracker/__init__.py b/sy_tools/tracker/__init__.py index f384347..5a92dfc 100644 --- a/sy_tools/tracker/__init__.py +++ b/sy_tools/tracker/__init__.py @@ -118,7 +118,8 @@ async def add_label(self, issue: str, label: str) -> dict: ... body_limit: int - """The largest body this tracker is believed to take, in characters. Best-effort, not a guarantee. + """The largest body this tracker is believed to take, in characters of the Markdown body Shipyard + sends. Best-effort, not a guarantee. Annotation only, with no value: a default here would put a non-verb into `vars()`, which is where `sy_tools/tests/tracker/test_canonical.py` pins the canonical verb set. From 3bd2cc4e1027ab410b79aa26e665666f4f06e8cb Mon Sep 17 00:00:00 2001 From: Brett Tully Date: Fri, 7 Aug 2026 09:32:06 +1000 Subject: [PATCH 10/14] AM-1321 - Bind the body_limit Protocol docstring figures to the constants too The third copy of each limit is the `body_limit` docstring on `TrackerAdapter`, which names both figures in prose. The per-adapter loop now checks that file alongside the matching ADAPTER.md, so editing a constant fails validation until every copy of its figure moves. Co-Authored-By: Claude Opus 5 --- scripts/validate.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/scripts/validate.py b/scripts/validate.py index d02edd8..a03bdb5 100755 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -798,15 +798,19 @@ def declared_body_limit(rel: str) -> int: if economy_axis_phrase in spec: fail("spec restates the prose-economy trigger; cite spec-gate.md instead of copying it", errors) - # Each adapter's body limit lives in the constant and again in its agent-facing ADAPTER.md prose. - # Both spellings count because the prose groups thousands and the code does not. + # Each adapter's body limit lives in the constant, again in its agent-facing ADAPTER.md prose, and + # again in the `body_limit` Protocol docstring that names both figures. Both spellings count in every + # place, because the prose groups thousands and the code does not. + protocol_rel = "sy_tools/tracker/__init__.py" + protocol = read(protocol_rel) for name, source, doc_rel, doc in ( ("jira", "sy_tools/tracker/jira/adapter.py", "skills/tracker/jira/ADAPTER.md", jira_adapter), ("github", "sy_tools/tracker/github/adapter.py", "skills/tracker/github/ADAPTER.md", github_adapter), ): limit = declared_body_limit(source) - if str(limit) not in doc and f"{limit:,}" not in doc: - fail(f"{name} adapter's body_limit is {limit} ({source}); {doc_rel} states a different figure", errors) + for target_rel, target in ((doc_rel, doc), (protocol_rel, protocol)): + if str(limit) not in target and f"{limit:,}" not in target: + fail(f"{name} adapter's body_limit is {limit} ({source}); {target_rel} states no such figure", errors) # `post-comment` takes `human` and `agent_detail`, both required, and assembles the boundary itself. # These are the highest-traffic call sites, so a reference still describing one hand-composed body From 22f6386578b3414c0db00e8a2201198ab8f88e2e Mon Sep 17 00:00:00 2001 From: Brett Tully Date: Fri, 7 Aug 2026 10:06:19 +1000 Subject: [PATCH 11/14] AM-1321 - Bump the plugin version past main and close three body_limit gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `plugin.json` claimed 1.19.0, which main already published; `claude plugin update` gates on that string alone, so a duplicate leaves installed copies pinned to stale content at their old version-keyed cache path. main has since published 1.20.0 (AM-1320), so this goes to 1.21.0. The body_limit assertion now also reads the comment block directly above each declaration — a fourth copy of the Jira figure that the declaration regex skipped — and refuses a declared limit under 8_192, because file-scoped containment lets a deleted leading digit (`32_767` -> `2_767`) pass on every target with all docs stale. CONTRACT.md's body-size clause ends at its conclusion: the Markdown-vs-converted-body mechanism is Jira-only inside a shared contract, and the sentence already delegates provenance to the adapter docs. --- .claude-plugin/plugin.json | 2 +- scripts/validate.py | 44 ++++++++++++++++++++++++++++++-------- skills/tracker/CONTRACT.md | 2 +- 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 7f7dde6..3c68ae0 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "sy", "displayName": "Shipyard", - "version": "1.19.0", + "version": "1.21.0", "description": "Disciplined plan -> spec -> ship workflow for Claude Code: adversarial review against pinned commits, briefs-not-transcripts context hygiene, and a full paper trail on a pluggable issue tracker (Jira or GitHub Projects).", "author": { "name": "Brett Tully" diff --git a/scripts/validate.py b/scripts/validate.py index 6c1e956..a65ab87 100755 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -64,6 +64,9 @@ # Every agent must be able to ask whether a credential is present without ever reading its value; # `sy_tools/guards/secret_guard.py` names this tool as the remedy it steers shell probes toward. CHECK_ENV_TOOL = "check_env" +# The lowest number an adapter's `body_limit` could plausibly be. Both real limits are 32k and 64k, and +# no tracker documents anything near this floor, so a declaration under it is a typo, not a limit. +BODY_LIMIT_FLOOR = 8_192 _SCRATCH_HINT = "the `sy` server's `scratch_dir` tool" _SCRATCH_REF_SUFFIXES = {".md", ".py", ".sh", ".json", ".yml", ".yaml", ".toml"} _SCRATCH_REF_PATTERN = re.compile(r"(? str: # Read out of the source rather than imported: `python scripts/validate.py` puts `scripts/` on # `sys.path`, not the repo root, so importing the adapters needs a `sys.path` shim this file has - # never needed for anything else. - def declared_body_limit(rel: str) -> int: - match = re.search(r"^ {4}body_limit: int = ([\d_]+)$", read(rel), re.MULTILINE) + # never needed for anything else. The comment block directly above the declaration comes back with + # it, because that block is a copy of the figure too and the same anchor already locates it. + def declared_body_limit(rel: str) -> tuple[int, str]: + match = re.search(r"((?:^ *#.*\n)*)^ {4}body_limit: int = ([\d_]+)$", read(rel), re.MULTILINE) if match is None: fail(f"{rel} must declare `body_limit: int = `; its ADAPTER.md figure cannot be checked", errors) - return 0 - return int(match.group(1)) + return 0, "" + return int(match.group(2)), match.group(1) ship = read("skills/ship/SKILL.md") handoff = read("skills/ship/references/handoff-accounting.md") @@ -864,19 +868,41 @@ def declared_body_limit(rel: str) -> int: if economy_axis_phrase in spec: fail("spec restates the prose-economy trigger; cite spec-gate.md instead of copying it", errors) - # Each adapter's body limit lives in the constant, again in its agent-facing ADAPTER.md prose, and - # again in the `body_limit` Protocol docstring that names both figures. Both spellings count in every - # place, because the prose groups thousands and the code does not. + # Each adapter's body limit lives in the constant, again in its agent-facing ADAPTER.md prose, again + # in the `body_limit` Protocol docstring that names both figures, and — for Jira — a fourth time in the + # comment above the declaration. Both spellings count in every place the figure is required, because + # the prose groups thousands and the code does not. protocol_rel = "sy_tools/tracker/__init__.py" protocol = read(protocol_rel) for name, source, doc_rel, doc in ( ("jira", "sy_tools/tracker/jira/adapter.py", "skills/tracker/jira/ADAPTER.md", jira_adapter), ("github", "sy_tools/tracker/github/adapter.py", "skills/tracker/github/ADAPTER.md", github_adapter), ): - limit = declared_body_limit(source) + limit, note = declared_body_limit(source) + # Containment is file-scoped substring, so a deleted leading digit collides with the grouped + # spelling it came from — `2_767` is found in every target stating `32,767`, and `5_536` in every + # target stating `65,536` — cutting the limit tenfold with every doc stale and this check green. + # A floor kills that whole class without parsing prose: no tracker limit is anywhere near this low. + if limit < BODY_LIMIT_FLOOR: + fail( + f"{name} adapter's body_limit is {limit} ({source}), under the {BODY_LIMIT_FLOOR} floor; no " + "tracker limit is that low, so this is a dropped digit, not a limit", + errors, + ) for target_rel, target in ((doc_rel, doc), (protocol_rel, protocol)): if str(limit) not in target and f"{limit:,}" not in target: fail(f"{name} adapter's body_limit is {limit} ({source}); {target_rel} states no such figure", errors) + # The comment above the declaration is a fourth copy in one adapter and names no figure in the + # other. Presence cannot be required of a comment, so the check is staleness: the figure it states, + # if it states one, is the declared one. It is the only copy the doc legs above never read. + spellings = (str(limit), f"{limit:,}", f"{limit:_}") + stale = [fig for fig in re.findall(r"(? Date: Fri, 7 Aug 2026 10:46:28 +1000 Subject: [PATCH 12/14] AM-1321 - Make the body_limit Protocol docstring tracker-neutral and de-vacuify its binding legs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `body_limit` docstring in `sy_tools/tracker/__init__.py` named Jira, Atlassian, JCMA, three JRACLOUD ids, `jira.text.field.character.limit`, Data Center, ADF and GitHub, which CONTRIBUTING.md forbids in a core module outside its adapter zone. It was green only because the seam test exempts the whole `sy_tools/tracker/` subtree. The docstring now states the contract alone; every figure and every provenance uncertainty already lives in each adapter's source comment and its own ADAPTER.md. The binding loop in `scripts/validate.py` drops its Protocol-docstring leg with the figures, and closes three holes in what remains: - The doc leg matched raw substrings, so a limit whose digits appear inside unrelated prose satisfied it vacuously — `59_124` passed with both docs stale, since that is a JRACLOUD id. It now requires a whole numeric token, reusing the staleness leg's extractor. - The staleness leg's anchor only reaches a comment block immediately above the declaration, so one blank line emptied the note and passed on a stale figure. An empty note is now a failure naming the shape required. - `declared_body_limit` returned a `0` sentinel that fell through into the floor leg and reported a second, fabricated fault. It returns `None` and the adapter is skipped. --- scripts/validate.py | 58 +++++++++++++++++++++++------------- sy_tools/tracker/__init__.py | 15 ++-------- 2 files changed, 40 insertions(+), 33 deletions(-) diff --git a/scripts/validate.py b/scripts/validate.py index 8a60fdd..d735718 100755 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -569,13 +569,21 @@ def read(rel: str) -> str: # `sys.path`, not the repo root, so importing the adapters needs a `sys.path` shim this file has # never needed for anything else. The comment block directly above the declaration comes back with # it, because that block is a copy of the figure too and the same anchor already locates it. - def declared_body_limit(rel: str) -> tuple[int, str]: + # `None`, not a sentinel figure: a `0` here fell through into the floor leg and reported a second, + # fabricated fault ("this is a dropped digit") about a declaration that was never read. + def declared_body_limit(rel: str) -> tuple[int, str] | None: match = re.search(r"((?:^ *#.*\n)*)^ {4}body_limit: int = ([\d_]+)$", read(rel), re.MULTILINE) if match is None: fail(f"{rel} must declare `body_limit: int = `; its ADAPTER.md figure cannot be checked", errors) - return 0, "" + return None return int(match.group(2)), match.group(1) + # Every grouped or ungrouped spelling of a four-digit-or-longer number in `target`. Used instead of + # substring containment, which a target satisfies for the wrong reason whenever the declared digits + # happen to appear inside some unrelated number in its prose. + def numeric_tokens(target: str) -> set[str]: + return set(re.findall(r"(? tuple[int, str]: if economy_axis_phrase in spec: fail("spec restates the prose-economy trigger; cite spec-gate.md instead of copying it", errors) - # Each adapter's body limit lives in the constant, again in its agent-facing ADAPTER.md prose, again - # in the `body_limit` Protocol docstring that names both figures, and — for Jira — a fourth time in the - # comment above the declaration. Both spellings count in every place the figure is required, because - # the prose groups thousands and the code does not. - protocol_rel = "sy_tools/tracker/__init__.py" - protocol = read(protocol_rel) + # Each adapter's body limit lives in the constant, again in the comment above it carrying that + # figure's provenance, and again in its agent-facing ADAPTER.md prose. The Protocol docstring is no + # longer a target: it states the contract and names no figure, because a core module may not name a + # concrete tracker (CONTRIBUTING.md) and a figure is worthless without its tracker-specific + # provenance. Both spellings count everywhere, because the prose groups thousands and the code does not. for name, source, doc_rel, doc in ( ("jira", "sy_tools/tracker/jira/adapter.py", "skills/tracker/jira/ADAPTER.md", jira_adapter), ("github", "sy_tools/tracker/github/adapter.py", "skills/tracker/github/ADAPTER.md", github_adapter), ): - limit, note = declared_body_limit(source) - # Containment is file-scoped substring, so a deleted leading digit collides with the grouped - # spelling it came from — `2_767` is found in every target stating `32,767`, and `5_536` in every - # target stating `65,536` — cutting the limit tenfold with every doc stale and this check green. - # A floor kills that whole class without parsing prose: no tracker limit is anywhere near this low. + declared = declared_body_limit(source) + if declared is None: + continue + limit, note = declared + spellings = {str(limit), f"{limit:,}", f"{limit:_}"} + # A dropped leading digit used to survive substring containment by colliding with the grouped + # spelling it came from — `2_767` occurs inside every target stating `32,767` — cutting the limit + # tenfold with every doc stale. Whole-token matching closes that, and a floor closes the rest of + # the class without parsing prose: no tracker limit is anywhere near this low. if limit < BODY_LIMIT_FLOOR: fail( f"{name} adapter's body_limit is {limit} ({source}), under the {BODY_LIMIT_FLOOR} floor; no " "tracker limit is that low, so this is a dropped digit, not a limit", errors, ) - for target_rel, target in ((doc_rel, doc), (protocol_rel, protocol)): - if str(limit) not in target and f"{limit:,}" not in target: - fail(f"{name} adapter's body_limit is {limit} ({source}); {target_rel} states no such figure", errors) - # The comment above the declaration is a fourth copy in one adapter and names no figure in the - # other. Presence cannot be required of a comment, so the check is staleness: the figure it states, - # if it states one, is the declared one. It is the only copy the doc legs above never read. - spellings = (str(limit), f"{limit:,}", f"{limit:_}") - stale = [fig for fig in re.findall(r"(? dict: `sy_tools/tests/tracker/test_canonical.py` pins the canonical verb set. It guards issue descriptions as well as comments, which is why it is not `comment_body_limit`. - Neither adapter's figure is firm, and both are stated with their provenance rather than presented - as spec: - - - Jira's 32,767 comes from Atlassian's own JCMA migration KB, which states that on Cloud "it's not - possible to bypass the 32,767 character limit for both description and comments" (citing - JRACLOUD-59124). The property behind it, `jira.text.field.character.limit`, is documented and - admin-tunable in Data Center only; JRACLOUD-63007 is Atlassian declining to expose it in Cloud - without disputing the reporter's premise that the same default applies there, and - JRACLOUD-68949 corroborates the description-field limit specifically. The *unit* is still - undocumented under ADF, so the figure remains best-effort even though its coverage of - descriptions is documented, not merely assumed. - - GitHub's 65,536 is undocumented entirely, attested by nothing but the API's own error string. + No figure here is firm. Each adapter declares its own, and records that figure's provenance — + what attests it and how far that attestation reaches — in its own `ADAPTER.md`, since provenance + is inherently tracker-specific and this module names no tracker. So a body under the limit is one this tracker has not been observed to refuse, not one it promises to accept. The point of the number is to turn the common overflow into a refusal a caller can act From 60e58aca8fa9816218f5a87efd6c0d7e9d431fe3 Mon Sep 17 00:00:00 2001 From: Brett Tully Date: Fri, 7 Aug 2026 11:09:15 +1000 Subject: [PATCH 13/14] AM-1321 - Correct a false docstring claim and unblind the figure extractor The body_limit docstring said 'this module names no tracker', which is false of its own module: adapter() names both. It is the Protocol that names none. numeric_tokens' trailing (?![\\w-]) made hyphenated figures invisible, so a provenance comment stating '32,767-character' read as figure-free and the staleness leg passed on stale prose. (?!\\w|-\\d) recovers those while still excluding JRACLOUD-59124 and date-shaped runs. Co-Authored-By: Claude Opus 5 --- scripts/validate.py | 2 +- sy_tools/tracker/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/validate.py b/scripts/validate.py index d735718..526cd78 100755 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -582,7 +582,7 @@ def declared_body_limit(rel: str) -> tuple[int, str] | None: # substring containment, which a target satisfies for the wrong reason whenever the declared digits # happen to appear inside some unrelated number in its prose. def numeric_tokens(target: str) -> set[str]: - return set(re.findall(r"(? dict: It guards issue descriptions as well as comments, which is why it is not `comment_body_limit`. No figure here is firm. Each adapter declares its own, and records that figure's provenance — what attests it and how far that attestation reaches — in its own `ADAPTER.md`, since provenance - is inherently tracker-specific and this module names no tracker. + is inherently tracker-specific and this Protocol names no tracker. So a body under the limit is one this tracker has not been observed to refuse, not one it promises to accept. The point of the number is to turn the common overflow into a refusal a caller can act From 54cfe5cdf41e8df74425bd231cb451a1cb90d279 Mon Sep 17 00:00:00 2001 From: Brett Tully Date: Fri, 7 Aug 2026 11:29:55 +1000 Subject: [PATCH 14/14] AM-1321 - Record why the figure extractor's boundaries are asymmetric CONTRIBUTING.md:22 asks for a comment wherever a reader would otherwise simplify something and break it invisibly. Making these two lookarounds symmetric is exactly that: it silently reopens the vacuity the previous commit closed, and every gate stays green. Co-Authored-By: Claude Opus 5 --- scripts/validate.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/validate.py b/scripts/validate.py index 526cd78..e31724a 100755 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -581,6 +581,12 @@ def declared_body_limit(rel: str) -> tuple[int, str] | None: # Every grouped or ungrouped spelling of a four-digit-or-longer number in `target`. Used instead of # substring containment, which a target satisfies for the wrong reason whenever the declared digits # happen to appear inside some unrelated number in its prose. + # + # The boundaries are deliberately asymmetric: the lookbehind rejects a leading hyphen so a citation + # id cannot be read as a figure, but the lookahead rejects a trailing hyphen only when a digit + # follows it. Making them symmetric loses `32,767-character`, which is how prose actually writes the + # figure, and a target stating only that reads as figure-free — so the staleness leg passes over + # stale provenance and the whole check goes quietly vacuous. def numeric_tokens(target: str) -> set[str]: return set(re.findall(r"(?