From 1dee9c74c885d165ace39b15c986200e7b612433 Mon Sep 17 00:00:00 2001 From: Sebastien Taggart Date: Tue, 24 Mar 2026 23:44:55 -0400 Subject: [PATCH 1/2] Move sync marker from first line to last line of generated files Claude CLI uses the first line of command files as preview text. With the marker at the top, every skill showed " You are executing the `/qa` skill. Your argument is: $ARGUMENTS --- @@ -159,3 +158,4 @@ After applying labels, tell the user what was done: - Never post the comment without showing it to the user first. - Never apply labels without user confirmation (the confirmation in Step 3 is the single gate for both posting the comment and applying labels). - Never merge, deploy, or take any action beyond labeling and commenting. + diff --git a/.claude/commands/release.md b/.claude/commands/release.md index 58c4a53..5642a08 100644 --- a/.claude/commands/release.md +++ b/.claude/commands/release.md @@ -1,4 +1,3 @@ - You are executing the `/release` skill. Your argument is: $ARGUMENTS --- @@ -17,9 +16,9 @@ git branch --show-current ``` **Determine mode:** -- If `` is empty → **Trunk Mode**. Follow the Trunk Mode section below. -- If `` is set but `` is empty → **Two-Branch Mode**. Follow the Two-Branch Mode section below. -- If both `` and `` are set → **Three-Branch Mode**. Follow the Three-Branch Mode section below. +- If `dev` is empty → **Trunk Mode**. Follow the Trunk Mode section below. +- If `dev` is set but `` is empty → **Two-Branch Mode**. Follow the Two-Branch Mode section below. +- If both `dev` and `` are set → **Three-Branch Mode**. Follow the Three-Branch Mode section below. --- @@ -116,9 +115,9 @@ Tell the user: ### Step 1 — Verify state -If not on ``, switch to it: +If not on `dev`, switch to it: ```bash -git checkout && git pull +git checkout dev && git pull ``` Verify a version tag exists on HEAD: @@ -130,9 +129,9 @@ If no tag is found, warn: "No version tag found on HEAD. Run `/version` first to ### Step 2 — Compute what's being promoted -Find all merge commits in `` not yet in `main`: +Find all merge commits in `dev` not yet in `main`: ```bash -git log main.. --merges --pretty=format:"%s" +git log main..dev --merges --pretty=format:"%s" ``` Parse PR numbers from the merge commit subjects. GitHub's format is: @@ -169,10 +168,10 @@ Have you tested all of the above on preview? Type 'release' to confirm. Wait for the user to type "release" or an explicit confirmation. Any other response → stop and ask what they'd like to change. -### Step 4 — Create PR: `` → `main` +### Step 4 — Create PR: `dev` → `main` ```bash -gh pr create --base main --head \ +gh pr create --base main --head dev \ --title "Release vX.Y.Z" \ --body "$(cat <<'EOF' Release vX.Y.Z @@ -260,7 +259,7 @@ Find all merge commits in `` not yet in `main`: git log main.. --merges --pretty=format:"%s" ``` -Parse PR numbers from the merge commit subjects. Note: some merge commits here may be promotion merges from `` — these are identifiable by subjects matching "Merge ... from ``". Include them in the list but note they are promotion merges; extract the original feature PRs from their PR bodies when possible. +Parse PR numbers from the merge commit subjects. Note: some merge commits here may be promotion merges from `dev` — these are identifiable by subjects matching "Merge ... from `dev`". Include them in the list but note they are promotion merges; extract the original feature PRs from their PR bodies when possible. For each PR number found, retrieve the PR body: ```bash @@ -358,3 +357,4 @@ After the command runs, note the release URL from the output. Tell the user: > "Released vX.Y.Z. Issues #N, #M closed automatically. GitHub Release vX.Y.Z created at ``. Run `make deploy-prod` to ship to production." + diff --git a/.claude/commands/review.md b/.claude/commands/review.md index de13cb9..d21fb26 100644 --- a/.claude/commands/review.md +++ b/.claude/commands/review.md @@ -1,4 +1,3 @@ - You are executing the `/review` skill. Your argument is: $ARGUMENTS --- @@ -55,3 +54,4 @@ After the review agent completes, relay its verdict to the user: - This command does not commit, push, or merge anything. It only reviews. - It may be called manually at any time, not just from `/ship`. - The review comment is posted to GitHub for the audit trail. + diff --git a/.claude/commands/setup.md b/.claude/commands/setup.md index 0fed24b..f79b347 100644 --- a/.claude/commands/setup.md +++ b/.claude/commands/setup.md @@ -1,4 +1,3 @@ - You are executing the `/setup` skill. Your argument is: $ARGUMENTS --- @@ -424,3 +423,4 @@ Add a note: `/start` can be used to create well-formed GitHub issues without wri - Never fetch more than 100 labels in a single command. `gh label list --limit 100` is the ceiling. - Do not skip any human gate in Phase 2 or Phase 3 — each write requires confirmation. - If the user skips a config value, do not ask again. Move on. + diff --git a/.claude/commands/ship.md b/.claude/commands/ship.md index 249eb63..53b3351 100644 --- a/.claude/commands/ship.md +++ b/.claude/commands/ship.md @@ -1,4 +1,3 @@ - You are executing the `/ship` skill. Your argument is: $ARGUMENTS --- @@ -24,7 +23,7 @@ git branch --show-current Determine which branches are protected (i.e., not a feature branch): - Always: `main` -- If `` is non-empty: also `` +- If `dev` is non-empty: also `dev` - If `` is non-empty: also `` If the current branch matches any of the above, **abort immediately** and say: @@ -89,7 +88,7 @@ git ls-files CODEOWNERS .github/CODEOWNERS docs/CODEOWNERS 2>/dev/null If the output is non-empty, inform the user: "CODEOWNERS file detected — GitHub will automatically request reviews from code owners." Determine the PR target branch: -- If `` is non-empty, target `` (two-branch or three-branch mode). +- If `dev` is non-empty, target `dev` (two-branch or three-branch mode). - Otherwise, target `main` (trunk mode). Determine the issue reference format: @@ -132,7 +131,7 @@ Wait for the review agent to complete and report its verdict. ## Step 7 — Act on verdict Determine merge command (used by all paths below): -- If in **trunk mode** (`` is empty): use `gh pr merge --merge` directly — `make merge` may refuse merges targeting `main`. +- If in **trunk mode** (`dev` is empty): use `gh pr merge --merge` directly — `make merge` may refuse merges targeting `main`. - Otherwise: use `make merge`. --- @@ -169,7 +168,7 @@ Return to the coding loop. When fixed, run `/ship` again from Step 1. ### After merge — QA label and success report -Apply the QA label only in **two-branch mode** (`` is set AND `` is empty): +Apply the QA label only in **two-branch mode** (`dev` is set AND `` is empty): - If `` is non-empty AND a linked issue number was identified in Step 3: ``` gh issue edit --add-label "" @@ -180,7 +179,7 @@ Apply the QA label only in **two-branch mode** (`` is set AND `` is empty): Report success based on mode: - **Trunk mode**: "PR merged. Issue #N closed automatically. Run `make deploy-prod` when ready to deploy to production." - **Two-branch mode**: "PR merged. Issues stay open until testing confirms the fix. Run `make deploy-preview` when ready to deploy to preview." -- **Three-branch mode**: "PR merged to ``. Promote to `` when ready for staging." +- **Three-branch mode**: "PR merged to `dev`. Promote to `` when ready for staging." --- @@ -190,5 +189,6 @@ Report success based on mode: - When `ai` is `"ai"`, never merge if the review verdict is REQUEST CHANGES. - When `ai` is `"advisory"`, always merge after review completes, regardless of verdict. - When `ai` is `"off"`, skip the review agent entirely — merge immediately after checks pass. -- In trunk mode, merges target `main`. Otherwise, `/ship` merges only to `` — never directly to `` or `main`. +- In trunk mode, merges target `main`. Otherwise, `/ship` merges only to `dev` — never directly to `` or `main`. - If `make merge` fails for any reason, report it and stop — do not attempt workarounds. + diff --git a/.claude/commands/start.md b/.claude/commands/start.md index cb645ae..cdac11e 100644 --- a/.claude/commands/start.md +++ b/.claude/commands/start.md @@ -1,4 +1,3 @@ - You are executing the `/start` skill. Your argument is: $ARGUMENTS --- @@ -209,3 +208,4 @@ When done, say: **"The code is ready for review. Please run `make dev` and test - `gh issue create` must use `--title` and `--body` flags. Never open an interactive editor. - The issue is assigned to `@me` at creation. If you are creating a ticket on someone else's behalf, remove the assignee after creation with `gh issue edit --remove-assignee @me`. - Apply resolved labels and milestone to every new issue. Label resolution order: per-invocation flag → pool selection from `` → omit (or create if `false` is `true`). Never apply a label not in `` unless `false` is `true`. Milestone resolution order: per-invocation flag → `` config → auto-detected from GitHub open milestones. Never prompt for a milestone more than once per invocation. + diff --git a/.claude/commands/status.md b/.claude/commands/status.md index acf4a70..65c177c 100644 --- a/.claude/commands/status.md +++ b/.claude/commands/status.md @@ -1,4 +1,3 @@ - You are executing the `/status` skill. Your argument is: $ARGUMENTS --- @@ -176,3 +175,4 @@ Do not post, comment, write files, or take any action. Output only. - If `gh` is unauthenticated or any fetch fails, report the error and stop immediately. - Do not retry failed commands. - Strip the leading `@` from the subject when passing to `gh` flags that do not accept it. + diff --git a/.claude/commands/version.md b/.claude/commands/version.md index cbb3322..8fa03d9 100644 --- a/.claude/commands/version.md +++ b/.claude/commands/version.md @@ -1,4 +1,3 @@ - You are executing the `/version` skill. Your argument is: $ARGUMENTS --- @@ -12,7 +11,7 @@ git branch --show-current Determine the required branch: - If `` is non-empty → required branch is `` (three-branch mode). -- Else if `` is non-empty → required branch is `` (two-branch mode). +- Else if `dev` is non-empty → required branch is `dev` (two-branch mode). - Else → required branch is `main` (trunk mode). If not on the required branch, abort and say: "Switch to `` before running `/version`." @@ -72,6 +71,7 @@ Both the version bump commit and the tag must be pushed. Tell the user the new version and tag: Report based on mode: -- **Trunk mode** (`` empty): "Tagged vX.Y.Z. Run `/release` to create the GitHub Release." -- **Two-branch mode** (`` set, `` empty): "Tagged vX.Y.Z. Run `make deploy-preview` to deploy to preview for testing. When testing is complete, run `/release`." +- **Trunk mode** (`dev` empty): "Tagged vX.Y.Z. Run `/release` to create the GitHub Release." +- **Two-branch mode** (`dev` set, `` empty): "Tagged vX.Y.Z. Run `make deploy-preview` to deploy to preview for testing. When testing is complete, run `/release`." - **Three-branch mode** (both set): "Tagged vX.Y.Z. Run `make deploy-preview` to deploy to staging. When testing is complete, run `/release`." + diff --git a/.claude/review-agent-prompt.md b/.claude/review-agent-prompt.md index 218527d..85601f5 100644 --- a/.claude/review-agent-prompt.md +++ b/.claude/review-agent-prompt.md @@ -1,4 +1,3 @@ - # Code Review Agent Prompt You are a code review agent for this project. You review feature branch pull requests before they are merged. @@ -106,3 +105,4 @@ If no findings: "No issues found. Code looks correct and follows project convent - Do not suggest improvements outside the scope of the PR. - Do not re-review files that haven't changed. - Your review is posted to GitHub for the audit trail — keep it professional. + diff --git a/sync.sh b/sync.sh index 252c351..0b085f6 100755 --- a/sync.sh +++ b/sync.sh @@ -120,7 +120,7 @@ def read_file_info(file_path): Returns (stored_hash, body_hash, is_generated) where: stored_hash — hash recorded in the marker line when the file was last written - body_hash — hash of the file's actual content (after the marker line) + body_hash — hash of the file's actual content (excluding the marker line) is_generated — True if the file was written by this sync tool (current or legacy marker) Comparing stored_hash vs body_hash tells us whether the file was edited @@ -132,17 +132,32 @@ def read_file_info(file_path): return None, None, False content = path.read_text() - parts = content.split('\n', 1) - first_line = parts[0] - - if not first_line_has_sync_marker(first_line): + lines = content.rstrip('\n').split('\n') + + # Marker can be on the last line (current) or the first line (legacy). + marker_line = None + marker_pos = None + if lines and first_line_has_sync_marker(lines[-1]): + marker_line = lines[-1] + marker_pos = 'end' + elif lines and first_line_has_sync_marker(lines[0]): + marker_line = lines[0] + marker_pos = 'start' + + if marker_line is None: return None, None, False - match = re.search(r'hash: ([a-f0-9]+)', first_line) + match = re.search(r'hash: ([a-f0-9]+)', marker_line) stored_hash = match.group(1) if match else None - body = parts[1] if len(parts) > 1 else '' - body_hash = content_hash(body) + if marker_pos == 'end': + body = '\n'.join(lines[:-1]) + '\n' + body_hash = content_hash(body) + else: + # Legacy position (start) — marker needs to move to end. + # Return a None body_hash to guarantee it won't match the fresh hash, + # forcing regeneration even if the body content is identical. + body_hash = None return stored_hash, body_hash, True @@ -213,8 +228,8 @@ def sync_skill(skill_path, adapter, project_config, project_root, args): # Compute hash (of content, before marker line) h = content_hash(full_content) - marker_line = f"\n" - final_content = marker_line + full_content + marker_line = f"" + final_content = full_content + marker_line + '\n' # Check existing file stored_hash, body_hash, is_generated = read_file_info(out_path) @@ -222,7 +237,7 @@ def sync_skill(skill_path, adapter, project_config, project_root, args): skill_type = fm.get('type', 'skill') type_tag = f" [{skill_type}]" if skill_type != 'skill' else '' - # body_hash is computed over the file content after the marker line, + # body_hash is computed over the file content excluding the marker line, # which is exactly what we'd write as full_content. If they match, we're done. if body_hash == h: print(f" ✓ {skill_name}{type_tag} (up to date)") From 600e9e6036be227e4126cee93bc0e1afd5228e95 Mon Sep 17 00:00:00 2001 From: Sebastien Taggart Date: Wed, 25 Mar 2026 08:02:03 -0400 Subject: [PATCH 2/2] Fix legacy marker migration: return marker position from read_file_info The initial implementation set body_hash=None for legacy files, which caused them to be flagged as "customized" and skipped. Instead, compute the real body_hash and return a needs_migration flag so sync_skill can force a rewrite even when content hashes match. Co-Authored-By: Claude Opus 4.6 (1M context) --- sync.sh | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/sync.sh b/sync.sh index 0b085f6..67c30ff 100755 --- a/sync.sh +++ b/sync.sh @@ -118,10 +118,11 @@ def read_file_info(file_path): """ Inspect an existing file for Code Cannon provenance (current or legacy sync marker). - Returns (stored_hash, body_hash, is_generated) where: + Returns (stored_hash, body_hash, is_generated, needs_migration) where: stored_hash — hash recorded in the marker line when the file was last written body_hash — hash of the file's actual content (excluding the marker line) is_generated — True if the file was written by this sync tool (current or legacy marker) + needs_migration — True if the marker is in legacy position (start) and should be moved to end Comparing stored_hash vs body_hash tells us whether the file was edited after the last sync. Comparing body_hash vs the freshly-computed h tells @@ -129,7 +130,7 @@ def read_file_info(file_path): """ path = Path(file_path) if not path.exists(): - return None, None, False + return None, None, False, False content = path.read_text() lines = content.rstrip('\n').split('\n') @@ -145,21 +146,23 @@ def read_file_info(file_path): marker_pos = 'start' if marker_line is None: - return None, None, False + return None, None, False, False match = re.search(r'hash: ([a-f0-9]+)', marker_line) stored_hash = match.group(1) if match else None if marker_pos == 'end': body = '\n'.join(lines[:-1]) + '\n' - body_hash = content_hash(body) else: - # Legacy position (start) — marker needs to move to end. - # Return a None body_hash to guarantee it won't match the fresh hash, - # forcing regeneration even if the body content is identical. - body_hash = None + # Legacy position (start) — extract body after marker line. + # The stored_hash was computed over this same body, so an unedited + # legacy file will pass the customization guard (body_hash == stored_hash) + # and then fail the up-to-date check (body_hash != h) because h is + # computed from the new full_content, triggering regeneration. + body = '\n'.join(lines[1:]) + '\n' + body_hash = content_hash(body) - return stored_hash, body_hash, True + return stored_hash, body_hash, True, (marker_pos == 'start') # ── Adapter loading ─────────────────────────────────────────────────────────── @@ -232,14 +235,15 @@ def sync_skill(skill_path, adapter, project_config, project_root, args): final_content = full_content + marker_line + '\n' # Check existing file - stored_hash, body_hash, is_generated = read_file_info(out_path) + stored_hash, body_hash, is_generated, needs_migration = read_file_info(out_path) skill_type = fm.get('type', 'skill') type_tag = f" [{skill_type}]" if skill_type != 'skill' else '' # body_hash is computed over the file content excluding the marker line, # which is exactly what we'd write as full_content. If they match, we're done. - if body_hash == h: + # Exception: if the marker is in legacy position, we must rewrite to migrate it. + if body_hash == h and not needs_migration: print(f" ✓ {skill_name}{type_tag} (up to date)") return False