diff --git a/.agents/skills/caveman-compress/README.md b/.agents/skills/caveman-compress/README.md index 150f9ac3..cf3abbb6 100644 --- a/.agents/skills/caveman-compress/README.md +++ b/.agents/skills/caveman-compress/README.md @@ -29,9 +29,19 @@ CLAUDE.original.md ← human-readable backup (you edit this) Original remains in data directory rather than next to live file, so skill auto-loaders do not read it twice. Path is -`$XDG_DATA_HOME/caveman-compress/backups//` on macOS and Linux, -or `%LOCALAPPDATA%\caveman-compress\backups\\` on Windows. Edit -`.original.md` there, then run skill again to re-compress. +`$XDG_DATA_HOME/caveman-compress/backups/-/` on macOS and +Linux, or `%LOCALAPPDATA%\caveman-compress\backups\-\` on +Windows. `` keys the file's full parent path, so `~/.claude/CLAUDE.md` and +`some-repo/.claude/CLAUDE.md` keep separate backups. + +To edit and re-compress, restore first — the skill refuses to run while a backup +exists, so that it never captures already-compressed text as the new "original": + +```bash +cp "$BACKUP" CLAUDE.md # restore the uncompressed original +mv "$BACKUP" "$BACKUP.kept" # move it aside (do not just delete it) +# edit CLAUDE.md, then run the skill again +``` ## Benchmarks @@ -158,7 +168,7 @@ validate output (no tokens) if errors: configured provider fixes cherry-picked issues only (tokens: targeted fix) does NOT recompress; only patches broken parts ↓ -retry up to 2 times +validate again (one fix call at most) ↓ write compressed → CLAUDE.md write original → CLAUDE.original.md diff --git a/.agents/skills/caveman-compress/SECURITY.md b/.agents/skills/caveman-compress/SECURITY.md index 1e301311..23824a43 100644 --- a/.agents/skills/caveman-compress/SECURITY.md +++ b/.agents/skills/caveman-compress/SECURITY.md @@ -10,7 +10,7 @@ The file being compressed is untrusted input, and opencode 2.x lets its agents use every tool that is not explicitly denied: shell, file edits, web search and fetch, MCP tools from your opencode config, subagents. Compression needs none of them. Compress therefore runs `opencode run --standalone --agent caveman-compress` with `OPENCODE_CONFIG_CONTENT` denying every permission (`"*": "deny"`) both globally and for that agent, and never passes `--auto`. The dedicated agent matters because opencode applies a per-agent rule after the global one, so an `agent.build.permission` allow in your own config would otherwise re-enable a tool. `--standalone` matters because the shared opencode background service does not read the caller's environment, so the deny rule only binds a private server. Checked on opencode 2.0.22: the model is offered no tools and the prompt attachment is still read. Unlike the Claude path (`--strict-mcp-config`), compress does not switch off the MCP servers in your opencode config: their tools are denied, but opencode may still start them. -2. **File read/write**: The skill reads the file the user explicitly points it at, compresses it, and writes the result back to the same path. A `.original.md` backup is saved to an out-of-tree data dir (`$XDG_DATA_HOME/caveman-compress/backups//`, or `%LOCALAPPDATA%\caveman-compress\backups\\` on Windows). The opencode provider writes a temporary prompt attachment in the OS temp directory and deletes it after the subprocess exits. Beyond the target file, that backup location, and the temporary attachment, no files are read or written. +2. **File read/write**: The skill reads the file the user explicitly points it at, compresses it, and writes the result back to the same path. A `.original.md` backup is saved to an out-of-tree data dir (`$XDG_DATA_HOME/caveman-compress/backups/-/`, or `%LOCALAPPDATA%\caveman-compress\backups\-\` on Windows, where `` keys the file's full parent path so two projects' same-named files never share a backup). The opencode provider writes a temporary prompt attachment in the OS temp directory and deletes it after the subprocess exits. Beyond the target file, that backup location, and the temporary attachment, no files are read or written. ### What the skill does NOT do diff --git a/.agents/skills/caveman-compress/SKILL.md b/.agents/skills/caveman-compress/SKILL.md index 141cf972..9110fa6e 100644 --- a/.agents/skills/caveman-compress/SKILL.md +++ b/.agents/skills/caveman-compress/SKILL.md @@ -2,19 +2,24 @@ name: caveman-compress description: > Compress a memory file such as CLAUDE.md or a todo list into caveman format - to save input tokens, keeping a readable backup. Trigger: /caveman-compress. + to save input tokens, keeping a readable backup. Sends the file's text to the + configured model provider. Trigger: /caveman-compress. --- # Caveman Compress ## Purpose +> **Data leaves the machine.** Compression sends the file's full text to the model provider that `scripts/compress.py` is configured for: the `claude` CLI, the Anthropic API, or an OpenAI-compatible endpoint. Files whose names look like secrets (`.env`, `credentials`, `secrets`, `.netrc`, …) are refused before they are read. Don't compress a file the provider shouldn't see. + Compress natural language files (CLAUDE.md, todos, preferences) into caveman-speak to reduce input tokens. Compressed version overwrites original. Human-readable backup saved as `.original.md`, but NOT beside the source file — it lives in an out-of-tree data dir (`$XDG_DATA_HOME/caveman-compress/backups//`, or `%LOCALAPPDATA%\caveman-compress\backups\\` on Windows) so skill auto-loaders don't re-ingest it as a live file. ## Trigger `/caveman-compress ` or when user asks to compress a memory file. +**Requires an explicit filepath.** If the user pastes raw text instead of naming a file, do NOT improvise an inline compression — ask them for the filepath to compress, or offer to save the pasted text to a file first. Writing your own reasoning/commentary about the request into the compressed output (e.g. "User passed text directly, not a filepath...") is the exact failure this guards against — never let that leak into a saved file. + ## Process 1. The compression scripts live in `scripts/` (adjacent to this SKILL.md). If the path is not immediately available, search for `scripts/__main__.py` next to this SKILL.md. diff --git a/.agents/skills/caveman-compress/scripts/cli.py b/.agents/skills/caveman-compress/scripts/cli.py index cc23b142..330410bc 100644 --- a/.agents/skills/caveman-compress/scripts/cli.py +++ b/.agents/skills/caveman-compress/scripts/cli.py @@ -28,7 +28,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) __package__ = "scripts" -from .compress import backup_dir_for, compress_file +from .compress import backup_dir_for, compress_file, is_sensitive_path from .detect import detect_file_type, should_compress @@ -48,6 +48,13 @@ def main(): filepath = filepath.resolve() + # Refuse before detect_file_type(), which reads extensionless files to sniff them. + if is_sensitive_path(filepath): + print(f"❌ Refusing to compress {filepath}: filename looks sensitive " + "(credentials, keys, secrets, or known private paths). " + "Rename the file if this is a false positive.") + sys.exit(1) + # Detect file type file_type = detect_file_type(filepath) @@ -70,7 +77,8 @@ def main(): print(f"Original: {backup_path}") sys.exit(0) else: - print("\n❌ Compression failed after retries") + # Also reached when no attempt ran (backup exists, empty file, ...). + print("\n❌ Compression did not complete (see above). Original file is untouched.") sys.exit(2) except KeyboardInterrupt: diff --git a/.agents/skills/caveman-compress/scripts/compress.py b/.agents/skills/caveman-compress/scripts/compress.py index 11dbd7ae..e34a975d 100644 --- a/.agents/skills/caveman-compress/scripts/compress.py +++ b/.agents/skills/caveman-compress/scripts/compress.py @@ -72,6 +72,7 @@ def split_frontmatter(text: str): r"(?ix)^(" r"\.env(\..+)?" r"|\.netrc" + r"|\.(npmrc|pypirc|pgpass|s3cfg|boto)" r"|credentials(\..+)?" r"|secrets?(\..+)?" r"|passwords?(\..+)?" @@ -105,8 +106,31 @@ def _state_base_dir(kind: str) -> Path: def backup_dir_for(filepath: Path) -> Path: - """Out-of-tree backup dir for filepath, keyed by its parent dir name — kept outside the source tree so skill auto-loaders don't re-ingest `.original.md` backups as live files.""" - return _state_base_dir("backups") / filepath.parent.name + """Out-of-tree backup dir for filepath — kept outside the source tree so skill + auto-loaders don't re-ingest `.original.md` backups as live files. + + Keyed on the file's RESOLVED PARENT PATH, not just the parent's name. Keying + on the name alone meant every project's `.claude/CLAUDE.md` shared one slot + (issue #1351): the second project aborted on the first project's backup, and + the abort's own advice — remove the backup and rerun — deleted another + project's only uncompressed original. The readable parent name is kept as a + prefix so the directory is still recognisable by eye; the digest is what + makes it unique. Resolving here (rather than trusting callers) keeps two + spellings of one file in a single slot. + """ + parent = Path(filepath).resolve().parent + digest = hashlib.sha256(str(parent).encode("utf-8")).hexdigest()[:12] + # A file directly under the filesystem root has an empty parent name. + return _state_base_dir("backups") / f"{parent.name or 'root'}-{digest}" + + +def legacy_backup_dir_for(filepath: Path) -> Path: + """The pre-#1351 backup dir (parent name only). Read-only: `_compress_file_locked` + still refuses to run when a backup sits here, so an install that compressed a + file before the key changed cannot silently re-backup its own compressed output + as the new "original". Never written to, and never deleted — the file it holds + may belong to a different project entirely, which is the bug that produced it.""" + return _state_base_dir("backups") / Path(filepath).resolve().parent.name LOCK_WAIT_SECONDS = 900 # must outlast a legitimate holder's worst-case run (up to MAX_RETRIES+1 Claude calls against the 500KB size cap) or a healthy wait misreads as a stuck lock @@ -625,9 +649,15 @@ def call_opencode_cli(prompt: str) -> str: "OPENCODE_CONFIG_CONTENT must be a strict JSON object (no comments or " "trailing commas) so compress can add its tool-deny rule; fix or unset it." ) + agents = config.get("agent") or {} + if not isinstance(agents, dict): + raise RuntimeError( + 'OPENCODE_CONFIG_CONTENT "agent" must be a JSON object so compress ' + "can add its tool-deny agent; fix or unset it." + ) config["permission"] = OPENCODE_PERMISSION_DENY config["agent"] = { - **(config.get("agent") or {}), + **agents, OPENCODE_AGENT: {"permission": OPENCODE_PERMISSION_DENY}, } env = {**os.environ, "OPENCODE_CONFIG_CONTENT": json.dumps(config)} @@ -753,7 +783,7 @@ def build_compress_prompt(original: str) -> str: - Preserve ALL URLs exactly - Preserve ALL headings exactly - Preserve file paths and commands -- Return ONLY the compressed markdown body — do NOT wrap the entire output in a ```markdown fence or any other fence. Inner code blocks from the original stay as-is; do not add a new outer fence around the whole file. +- Return ONLY the compressed markdown body, with no preface, commentary, or YAML frontmatter — do NOT wrap the entire output in a ```markdown fence or any other fence. Inner code blocks from the original stay as-is; do not add a new outer fence around the whole file. Only compress natural language prose. Never restructure, add formatting, or improve organization. @@ -787,7 +817,7 @@ def build_fix_prompt(original: str, compressed: str, errors: List[str]) -> str: COMPRESSED (fix this): {compressed} -Return ONLY the fixed compressed file. No explanation. +Return ONLY the fixed compressed markdown body. No preface, commentary, YAML frontmatter, or explanation. """ @@ -877,6 +907,32 @@ def restore_code_blocks(text: str, blocks: List[Tuple[str, str]]) -> str: return restored +def _frontmatter_block_count(masked_body: str) -> int: + """Count mapping-shaped delimiter blocks outside preserved code.""" + lines = masked_body.splitlines() + delimiters = [i for i, line in enumerate(lines) if line == "---"] + count = 0 + for start, end in zip(delimiters, delimiters[1:]): + content = [line for line in lines[start + 1:end] if line.strip()] + key_value = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]*:(\s.*)?$") + if content and key_value.match(content[0]) and all( + key_value.match(line) or line[0].isspace() or line.startswith("- ") + for line in content[1:] + ): + count += 1 + return count + + +def _body_candidate_error(original_masked: str, candidate_masked: str) -> str: + """Use the same body-only structural guards for compression and repair.""" + anchor = first_nonblank_line(original_masked) + if anchor.startswith(("#", CODE_MARKER_PREFIX)) and first_nonblank_line(candidate_masked) != anchor: + return "output does not start with the original body's first line (possible preamble leak)" + if _frontmatter_block_count(candidate_masked) > _frontmatter_block_count(original_masked): + return "output added YAML frontmatter to the markdown body" + return "" + + # ---------- Core Logic ---------- @@ -906,10 +962,24 @@ def compress_file(filepath: Path) -> bool: ) with file_lock(filepath): - return _compress_file_locked(filepath) - - -def _compress_file_locked(filepath: Path) -> bool: + backup_path = backup_dir_for(filepath) / (filepath.stem + ".original.md") + had_backup = backup_path.exists() + run = {"live_write_started": False} + try: + return _compress_file_locked(filepath, run) + except BaseException: + # A provider error, timeout or Ctrl-C mid-run must not leave the + # staging file next to the source, or a backup that makes every + # later run abort with "Backup file already exists". The backup is + # removed only if this run created it and never started the live + # write; past that point it may be the only copy of the original. + filepath.with_name(filepath.name + ".caveman-staged").unlink(missing_ok=True) + if not had_backup and not run["live_write_started"]: + backup_path.unlink(missing_ok=True) + raise + + +def _compress_file_locked(filepath: Path, run: dict) -> bool: """Body of compress_file; runs entirely under compress_file's file_lock for this resolved path.""" print(f"Processing: {filepath}") @@ -928,11 +998,35 @@ def _compress_file_locked(filepath: Path) -> bool: print("❌ Refusing to compress: file is empty or whitespace-only.") return False - # Check if backup already exists to prevent accidental overwriting + # Refuse when a backup already exists, so a rerun cannot capture this file's + # already-compressed text as the new "original". if backup_path.exists(): - print(f"⚠️ Backup file already exists: {backup_path}") - print("The original backup may contain important content.") - print("Aborting to prevent data loss. Please remove or rename the backup file if you want to proceed.") + print(f"⚠️ Backup already exists: {backup_path}") + print(f" It holds the uncompressed original of an earlier run on {filepath.name}.") + print("Aborting to prevent data loss.") + print(f" To restore it: cp '{backup_path}' '{filepath}'") + print(f" To start over: mv '{backup_path}' '{backup_path}.kept'") + print(" Do not just delete it — rerunning would then store the ALREADY") + print(" COMPRESSED text as the new original, losing the uncompressed version.") + return False + + # The pre-#1351 location is checked too: an install that compressed this file + # before the backup key changed still has its only original there. That path + # was keyed on the parent directory's NAME alone, so it was shared by every + # same-named file on the machine — this backup may well belong to a DIFFERENT + # project, which is exactly the bug that produced it. So: refuse, name the + # ambiguity, and never read, move or delete it on the user's behalf. + legacy_backup = legacy_backup_dir_for(filepath) / (filepath.stem + ".original.md") + if legacy_backup.exists(): + print(f"⚠️ A backup from an older caveman-compress exists: {legacy_backup}") + print(" Older versions keyed backups on the parent directory's name only,") + print(f" so that file is the original of SOME {filepath.parent.name}/{filepath.name}") + print(" on this machine — possibly this one, possibly another project's.") + print("Aborting to prevent data loss.") + print(" Check its contents, then move it out of the way to proceed:") + print(f" mv '{legacy_backup}' '{legacy_backup}.kept'") + print(" Backups written from now on are keyed on the file's full path and") + print(f" will land in {backup_path.parent}") return False # Split YAML frontmatter off before compression. LLMs tend to strip or @@ -950,11 +1044,16 @@ def _compress_file_locked(filepath: Path) -> bool: provider = configured_provider() print(f"Compressing with {provider}...") masked_body, code_blocks = mask_code_blocks(body) - nocompress_regions = [ - block for _, block in code_blocks - if NOCOMPRESS_OPEN_RE.match(block.split("\n", 1)[0]) - ] masked_compressed = call_claude(build_compress_prompt(masked_body)) + if masked_compressed is None or not masked_compressed.strip(): + print(f"❌ Compression aborted: {provider} returned an empty response.") + print(" Original file is untouched (no backup created).") + return False + body_error = _body_candidate_error(masked_body, masked_compressed) + if body_error: + print(f"❌ Compression aborted: {body_error}.") + print(" Original file is untouched (no backup created).") + return False try: compressed_body = restore_code_blocks(masked_compressed, code_blocks) except ValueError as error: @@ -1013,6 +1112,7 @@ def _compress_file_locked(filepath: Path) -> bool: if result.is_valid: print("Validation passed") + run["live_write_started"] = True _write_target(filepath, compressed, backup_path, newline) staging_path.unlink(missing_ok=True) return True @@ -1029,7 +1129,7 @@ def _compress_file_locked(filepath: Path) -> bool: print(f"Fixing with {provider}...") fixed = call_claude( - build_fix_prompt(original_text, compressed, result.errors) + build_fix_prompt(body, compressed_body, result.errors) ) if fixed is None or not fixed.strip(): @@ -1037,36 +1137,31 @@ def _compress_file_locked(filepath: Path) -> bool: print(" Skipping this attempt.") continue - # Guard against a prose preamble smuggled in ahead of the real fixed - # content (issue #588). Only enforced when the original starts with a - # structural anchor (frontmatter `---` or a heading) — plain-prose - # first lines get legitimately rewritten by compression, and requiring - # them verbatim would reject every valid fix. - anchor = first_nonblank_line(original_text) - if anchor.startswith(("---", "#")) and first_nonblank_line(fixed) != anchor: - print("❌ Fix attempt aborted: output does not start with the original's first line.") - print(" Possible preamble leak. Skipping this attempt.") + # Repairs use the same body-only contract as the initial compression. + # Mask code for structural checks so fenced YAML is never frontmatter. + try: + masked_fixed, _ = mask_code_blocks(fixed) + except ValueError as error: + print(f"❌ Fix attempt aborted: {error}") continue - - # The repaired candidate is what gets written if it validates, so the - # non-expansion invariant has to hold for it too — a repair that - # restores the structure validate() asked for by padding the prose back - # out is exactly the "compression" #776 is about. `fixed` is a whole - # file (build_fix_prompt is given one, and the anchor check above - # requires it to start with the original's first line), so its - # frontmatter is split off to compare like against like. - _, fixed_body = split_frontmatter(fixed) + body_error = _body_candidate_error(masked_body, masked_fixed) + if body_error: + print(f"❌ Fix attempt aborted: {body_error}.") + print(" Skipping this attempt.") + continue + fixed_body = fixed if not _is_smaller_than_body(fixed_body, body): print(" Skipping this attempt.") continue - # The fix prompt carries regions unmasked and - # validate() never compares prose, so check them verbatim here. - if any(block.rstrip("\n") not in fixed for block in nocompress_regions): - print("❌ Fix attempt aborted: output changed a region.") + # The fix prompt carries the body unmasked; every protected block + # must still come back verbatim, including list-nested fences. + if any(block.rstrip("\n") not in fixed_body for _, block in code_blocks): + print("❌ Fix attempt aborted: output changed a preserved code or block.") print(" Skipping this attempt.") continue - compressed = fixed + compressed_body = fixed_body + compressed = frontmatter + compressed_body return False diff --git a/.agents/skills/caveman-compress/scripts/validate.py b/.agents/skills/caveman-compress/scripts/validate.py index d68935fc..53eba1e2 100644 --- a/.agents/skills/caveman-compress/scripts/validate.py +++ b/.agents/skills/caveman-compress/scripts/validate.py @@ -222,12 +222,17 @@ def extract_fenced_spans(lines): return spans +# Sentence punctuation right after a URL or path ("see docs/a.md.") is matched +# by both regexes but is not part of it; compression often moves it. +TRAILING_PUNCTUATION = ".,;:!?" + + def extract_urls(text): - return set(URL_REGEX.findall(text)) + return {url.rstrip(TRAILING_PUNCTUATION) for url in URL_REGEX.findall(text)} def extract_paths(text): - return set(PATH_REGEX.findall(text)) + return {path.rstrip(TRAILING_PUNCTUATION) for path in PATH_REGEX.findall(text)} def count_bullets(text): diff --git a/.agents/skills/caveman-learn/SKILL.md b/.agents/skills/caveman-learn/SKILL.md index 16f6f59b..8dc2c2d4 100644 --- a/.agents/skills/caveman-learn/SKILL.md +++ b/.agents/skills/caveman-learn/SKILL.md @@ -63,9 +63,11 @@ re-pasted every turn. The candidate carries only a LOCATOR — never the block b order), pick block_index, and verify that sha256 of the raw block equals the locator's content_sha256. If it does not match, the file changed since the scan — abort this item. -- Store it: caveman mem remember -- "" and capture the returned id. - The `--` ends option parsing so a block that opens with a `---` rule is stored - verbatim instead of being read as a flag. +- Store it: write the real block byte for byte to a temp file with your file tool, + then run: caveman mem remember --stdin < and capture the returned id. + Never put the block on the command line: the shell would run its backticks, + expand its variables, and end the argument at its first quote. Delete the temp + file once the id is back. - Measure the gate honestly. before = the block's tokens/turn (it loaded every turn). after = the pointer's tokens/turn plus the recall cost. Get the recall cost by running caveman mem recall "" and reading tokens_added on the hit. If after diff --git a/.agents/skills/caveman-learn/tests/skill-file.test.mjs b/.agents/skills/caveman-learn/tests/skill-file.test.mjs index a1d522d3..838351b3 100644 --- a/.agents/skills/caveman-learn/tests/skill-file.test.mjs +++ b/.agents/skills/caveman-learn/tests/skill-file.test.mjs @@ -26,6 +26,8 @@ test("SKILL.md covers the cavemem_offload move", () => { assert.match(skill, /cavemem_offload/, "must describe the offload fix kind"); assert.match(skill, /content_sha256/, "must verify the block against its content hash"); assert.match(skill, /caveman mem recover/, "must name the byte-exact recovery path"); + assert.match(skill, /caveman mem remember --stdin/, "must store the block from stdin"); + assert.doesNotMatch(skill, /mem remember -- "/, "must never put the block on a shell command line"); }); test("SKILL.md closes the longitudinal outcome loop honestly", () => { diff --git a/.agents/skills/ponytail-audit/SKILL.md b/.agents/skills/ponytail-audit/SKILL.md index d90ae03f..ff9a9548 100644 --- a/.agents/skills/ponytail-audit/SKILL.md +++ b/.agents/skills/ponytail-audit/SKILL.md @@ -60,7 +60,7 @@ tested and fixed later. This is a report the user asked for, so give it in full. wrong result". No case, no finding. - Before calling code unused, grep the whole tree for it, including tests, fixtures, config, and string or dynamic references. -- A shortcut marked with a `ponytail:` comment that names its limit is a +- A shortcut marked with a `shortcut:` (or older `ponytail:`) comment that names its limit is a decision, not a finding, unless the expected load already crosses it. - Propose the smallest fix that works. Prefer fixes that delete code. Never add layers, frameworks or config the problem does not need. diff --git a/.agents/skills/ponytail-debt/SKILL.md b/.agents/skills/ponytail-debt/SKILL.md index f74a0d2c..3124f520 100644 --- a/.agents/skills/ponytail-debt/SKILL.md +++ b/.agents/skills/ponytail-debt/SKILL.md @@ -1,24 +1,27 @@ --- name: ponytail-debt description: > - List every `ponytail:` shortcut comment as a debt ledger. One-shot report, + List every `shortcut:` comment (and older `ponytail:` ones) as a debt ledger. One-shot report, changes nothing. Use for "ponytail debt", "what did ponytail defer", "list the shortcuts", /ponytail-debt. --- -Every deliberate ponytail shortcut is marked with a `ponytail:` comment naming -its ceiling and upgrade path. This collects them into one ledger so a deferral +Every deliberate ponytail shortcut is marked with a `shortcut:` comment naming +its ceiling and upgrade path (older code may say `ponytail:`). This collects them into one ledger so a deferral can't quietly become permanent. ## Scan -Grep the repo for comment markers, skipping `node_modules`, `.git`, and build -output: +Grep the repo for comment markers, skipping `.git` and everything the +`.gitignore` files ignore (dependencies, build output, caches, in any stack): -`grep -rnE --exclude-dir=.git --exclude-dir=node_modules --exclude-dir=dist --exclude-dir=build '(#|//|/[*]) ?ponytail:' .` (add other comment prefixes if your stack uses them) +`git grep --no-index --exclude-standard -nE '(#|//|/[*]) ?(shortcut|ponytail):'` (add other comment prefixes if your stack uses them) + +If the user names their own marker word (`/ponytail-debt TODO`), grep for that word instead. Each hit is one ledger row. The comment prefix keeps prose that merely mentions -the convention out of the ledger. +the convention out of the ledger. Skip hits that are not a deferral, like a note +about a keyboard shortcut. ## Output @@ -26,17 +29,16 @@ One row per marker, grouped by file: `:, . ceiling: . upgrade: .` -The convention is `ponytail: , `, so pull the ceiling +The convention is `shortcut: , `, so pull the ceiling and the trigger straight from the comment. Want an owner per row too? add `git blame -L,`. -Flag the rot risk: any `ponytail:` comment that names no upgrade path or +Flag the rot risk: any marker comment that names no upgrade path or trigger gets a `no-trigger` tag, those are the ones that silently rot. -End with ` markers, with no trigger.` Nothing found: `No ponytail: debt. Clean ledger.` +End with ` markers, with no trigger.` Nothing found: `No shortcut debt. Clean ledger.` ## Boundaries Reads and reports only, changes nothing. To persist it, ask and it writes the -ledger to a file (e.g. `PONYTAIL-DEBT.md`). One-shot. "stop ponytail-debt" or -"normal mode" to revert. +ledger to a file (e.g. `PONYTAIL-DEBT.md`). One-shot. diff --git a/.agents/skills/ponytail-gain/SKILL.md b/.agents/skills/ponytail-gain/SKILL.md index 1cc796c9..1edff0e4 100644 --- a/.agents/skills/ponytail-gain/SKILL.md +++ b/.agents/skills/ponytail-gain/SKILL.md @@ -50,4 +50,3 @@ this card points there instead of inventing one. ## Boundaries One-shot display. Edits nothing, changes no mode. -"stop ponytail" or "normal mode": revert. diff --git a/.agents/skills/ponytail-help/SKILL.md b/.agents/skills/ponytail-help/SKILL.md index 54e6beb6..34fccb3f 100644 --- a/.agents/skills/ponytail-help/SKILL.md +++ b/.agents/skills/ponytail-help/SKILL.md @@ -27,7 +27,7 @@ Level sticks until changed or session end. | **ponytail** | `/ponytail` | Lazy mode itself: least new code, clear replies that name skipped work and risks. | | **ponytail-review** | `/ponytail-review` | Quality review of a diff: bugs, security, load, missing tests, speed, what to cut. Each finding says what goes wrong and how to fix it. | | **ponytail-audit** | `/ponytail-audit` | The same quality review for the whole repo, ranked. | -| **ponytail-debt** | `/ponytail-debt` | Harvest `ponytail:` shortcut comments into a tracked ledger. | +| **ponytail-debt** | `/ponytail-debt` | Harvest `shortcut:` comments into a tracked ledger. | | **ponytail-gain** | `/ponytail-gain` | Measured-impact scoreboard: less code, less cost, more speed. | | **ponytail-help** | `/ponytail-help` | This card. | @@ -40,6 +40,12 @@ slash-command forms above (OpenCode ships all six as slash commands). Say "stop ponytail" or "normal mode". Resume anytime with `/ponytail`. `/ponytail off` also works. +## Band (Claude Code) + +`/ponytail band` hides the band above the prompt, the same command brings it back. +`/ponytail band on` and `/ponytail band off` set it directly. The level stays. +`/ponytail band import` adds your past Claude Code sessions (cost, agent time, tokens) to the board. + ## Configure Default Mode Default mode = `full`, auto-active every session. Change it: diff --git a/.agents/skills/ponytail-review/SKILL.md b/.agents/skills/ponytail-review/SKILL.md index ca88f298..a50e3ac6 100644 --- a/.agents/skills/ponytail-review/SKILL.md +++ b/.agents/skills/ponytail-review/SKILL.md @@ -60,7 +60,7 @@ This is a report the user asked for, so give it in full. wrong result". No case, no finding. - Re-read the lines and confirm: the caller exists, the value can really be empty, the code really is unused. -- A shortcut marked with a `ponytail:` comment that names its limit is a +- A shortcut marked with a `shortcut:` (or older `ponytail:`) comment that names its limit is a decision, not a finding, unless the expected load already crosses it. - Propose the smallest fix that works. Prefer fixes that delete code. Never add layers, frameworks or config the problem does not need. diff --git a/.agents/skills/ponytail/SKILL.md b/.agents/skills/ponytail/SKILL.md index 32ac909b..d64edd32 100644 --- a/.agents/skills/ponytail/SKILL.md +++ b/.agents/skills/ponytail/SKILL.md @@ -6,7 +6,7 @@ description: > fixing, refactoring, reviewing, choosing dependencies) and when the user says "ponytail", "be lazy", "simplest solution", "yagni", or complains about over-engineering or bloat. Levels: lite, full (default), ultra. -argument-hint: "[lite|full|ultra]" +argument-hint: "[lite|full|ultra|off]" license: MIT --- @@ -39,7 +39,7 @@ Take the first option that fully works: - Code you move or merge keeps its error handling and validation. - Between options of equal size, take the one that is correct on edge cases. - Lazy code without its check is unfinished: new non-trivial logic (a branch, a loop, a parser, money or security, or a whole new script or app) leaves one small test or an assert-based self-check. Trivial changes need none. -- A shortcut with a known limit gets a `ponytail:` comment that names the limit and when to upgrade. +- A shortcut with a known limit gets a code comment in this form: `shortcut: , `. Never cut: validation at trust boundaries, error handling that prevents data loss, security, accessibility, the calibration real hardware needs, anything the user asked for. diff --git a/package-lock.json b/package-lock.json index 1c7a4c09..0e4df5f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tomgrv/devcontainer-features", - "version": "11.2.0", + "version": "11.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tomgrv/devcontainer-features", - "version": "11.2.0", + "version": "11.9.0", "workspaces": [ "src/*", ".mcp" @@ -45,7 +45,7 @@ }, ".mcp": { "name": "@tomgrv/devcontainer-features-mcp", - "version": "10.0.0", + "version": "11.4.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.32.1", "zod": "^3.24.0" @@ -7195,7 +7195,7 @@ }, "src/ai-coding": { "name": "@tomgrv/devcontainer-features-ai-coding", - "version": "11.2.0", + "version": "11.9.0", "peerDependencies": { "@tomgrv/devcontainer-features-common-utils": "*" }, @@ -7207,7 +7207,7 @@ }, "src/common-utils": { "name": "@tomgrv/devcontainer-features-common-utils", - "version": "11.2.0", + "version": "11.5.0", "hasInstallScript": true }, "src/gateway": { @@ -7229,7 +7229,7 @@ }, "src/githooks": { "name": "@tomgrv/devcontainer-features-githooks", - "version": "11.2.0", + "version": "11.4.0", "hasInstallScript": true, "dependencies": { "@commitlint/cli": "*", diff --git a/package.json b/package.json index bf5d6b25..ff406996 100644 --- a/package.json +++ b/package.json @@ -1,224 +1,224 @@ { - "name": "@tomgrv/devcontainer-features", - "description": "Configure dev environment with devcontainer, gitflow, gitversion, git aliases & hooks. Can be used a devcontainer features", - "version": "11.9.0", - "type": "module", - "config": { - "commitizen": { - "path": "@commitlint/cz-commitlint" - }, - "local": [ - "gitutils", - "githooks" - ] - }, - "author": "tomgrv", - "bin": { - "devcontainer-features": "install.sh", - "tomgrv-devcontainer-features": "install.sh" - }, - "bugs": { - "email": "project@hostname.com", - "url": "https://github.com/tomgrv/devcontainer-features/issues" - }, - "bump-changelog": { - "types": [ - { - "type": "feat", - "section": "Features" - }, - { - "type": "fix", - "section": "Bug Fixes" - } - ] - }, - "bump-version": { - "files": [ - { - "filename": "composer.json", - "type": "json" - }, - { - "filename": "package.json", - "type": "json" - }, - { - "filename": "package.json", - "type": "json@ws" - }, - { - "filename": "devcontainer-feature.json", - "type": "json@ws" - }, - { - "filename": "VERSION", - "type": "plain-text" - } - ] - }, - "commit-and-tag-version": { - "scripts": { - "prebump": "gitversion -config .gitversion -showvariable SemVer" - } - }, - "commitlint": { - "extends": [ - "@commitlint/config-conventional", - "@commitlint/config-workspace-scopes" - ], - "rules": { - "subject-case": [ - 2, - "never", - [ - "start-case", - "pascal-case", - "upper-case" + "name": "@tomgrv/devcontainer-features", + "description": "Configure dev environment with devcontainer, gitflow, gitversion, git aliases & hooks. Can be used a devcontainer features", + "version": "11.9.0", + "type": "module", + "config": { + "commitizen": { + "path": "@commitlint/cz-commitlint" + }, + "local": [ + "gitutils", + "githooks" ] - ] - } - }, - "dependencies": { - "prettier-plugin-sh": "^0.19.0" - }, - "devDependencies": { - "husky": "*", - "npm-check-updates": "*" - }, - "files": [ - "./install*.sh", - "./setup.sh", - "./src/*", - "./stubs/*" - ], - "funding": { - "type": "individual", - "url": "https://buymeacoffee.com/tomgrv" - }, - "git-precommit-checks": { - "rules": [ - { - "message": "You've got leftover conflict markers", - "regex": "/^[<>|=]{4,}/m" - }, - { - "filter": "(^package\\.json|\\.git-precommit-checks.json)$", - "message": "You have unfinished devs", - "nonBlocking": "true", - "regex": "(?:FIXME|TODO)" - } - ] - }, - "homepage": "https://github.com/tomgrv/devcontainer-features#readme", - "keywords": [ - "dev", - "git", - "hooks", - "commitlint", - "commitizen", - "lint-staged", - "prettier", - "devmoji", - "git-precommit-checks", - "husky", - "conventional-changelog", - "gitversion", - "gitflow", - "changelog", - "semver" - ], - "lint-staged": { - "!(*schema|package-lock).json": [ - "json-normalize -c -w -a -i -t 4 -f local -l true" - ], - "!(templates/**/*|.agents/**|**/package-lock|package-lock).{js,jsx,ts,tsx,md,html,css,vue,yaml,yml,json}": [ - "npx --yes prettier --write" - ], - "*.php": [ - "composer lint" - ], - ".devcontainer/*": [ - "cp -u -t ./stubs/.devcontainer " - ], - "composer.json": [ - "composer validate --no-check-all --no-check-lock --no-check-publish" - ], - "package.json": [ - "npm pkg fix" - ] - }, - "optionalDependencies": { - "@tomgrv/devcontainer-features-common-utils": ">=5" - }, - "overrides": { - "uuid": "^11.0.0" - }, - "peerDependencies": { - "@commitlint/cli": "^19.2.2", - "@commitlint/config-conventional": "^19.2.2", - "@commitlint/core": "^19.2.2", - "@commitlint/cz-commitlint": "^19.2.0", - "commit-and-tag-version": "^12.4.1", - "commitizen": "^4.3.0", - "devmoji": "^2.3.0", - "git-precommit-checks": "3.1.0", - "lint-staged": "^15.2.2", - "prettier": "^3.2.5", - "prettier-plugin-sh": "*" - }, - "prettier": { - "insertPragma": true, - "overrides": [ - { - "files": "*.{yml,yaml}", - "options": { - "tabWidth": 2, - "useTabs": false - } - }, - { - "files": "*.json", - "options": { - "tabWidth": 4, - "useTabs": false + }, + "author": "tomgrv", + "bin": { + "devcontainer-features": "install.sh", + "tomgrv-devcontainer-features": "install.sh" + }, + "bugs": { + "email": "project@hostname.com", + "url": "https://github.com/tomgrv/devcontainer-features/issues" + }, + "bump-changelog": { + "types": [ + { + "type": "feat", + "section": "Features" + }, + { + "type": "fix", + "section": "Bug Fixes" + } + ] + }, + "bump-version": { + "files": [ + { + "filename": "composer.json", + "type": "json" + }, + { + "filename": "package.json", + "type": "json" + }, + { + "filename": "package.json", + "type": "json@ws" + }, + { + "filename": "devcontainer-feature.json", + "type": "json@ws" + }, + { + "filename": "VERSION", + "type": "plain-text" + } + ] + }, + "commit-and-tag-version": { + "scripts": { + "prebump": "gitversion -config .gitversion -showvariable SemVer" } - }, - { - "files": "*.yml", - "options": { - "tabWidth": 2, - "useTabs": false + }, + "commitlint": { + "extends": [ + "@commitlint/config-conventional", + "@commitlint/config-workspace-scopes" + ], + "rules": { + "subject-case": [ + 2, + "never", + [ + "start-case", + "pascal-case", + "upper-case" + ] + ] } - } + }, + "dependencies": { + "prettier-plugin-sh": "^0.19.0" + }, + "devDependencies": { + "husky": "*", + "npm-check-updates": "*" + }, + "files": [ + "./install*.sh", + "./setup.sh", + "./src/*", + "./stubs/*" ], - "plugins": [ - "prettier-plugin-sh" + "funding": { + "type": "individual", + "url": "https://buymeacoffee.com/tomgrv" + }, + "git-precommit-checks": { + "rules": [ + { + "message": "You've got leftover conflict markers", + "regex": "/^[<>|=]{4,}/m" + }, + { + "filter": "(^package\\.json|\\.git-precommit-checks.json)$", + "message": "You have unfinished devs", + "nonBlocking": "true", + "regex": "(?:FIXME|TODO)" + } + ] + }, + "homepage": "https://github.com/tomgrv/devcontainer-features#readme", + "keywords": [ + "dev", + "git", + "hooks", + "commitlint", + "commitizen", + "lint-staged", + "prettier", + "devmoji", + "git-precommit-checks", + "husky", + "conventional-changelog", + "gitversion", + "gitflow", + "changelog", + "semver" ], - "semi": false, - "singleQuote": true, - "tabWidth": 4, - "trailingComma": "es5" - }, - "private": true, - "repository": { - "type": "git", - "url": "git+https://github.com/tomgrv/devcontainer-features.git" - }, - "scripts": { - "lint": "npx --yes lint-staged", - "prepare": "sh .husky/install.sh || echo 'Husky not installed in ' $(pwd)", - "release": "commit-and-tag-version --no-verify --", - "test": "bats -r tests src", - "update": "npm-check-updates -i -u", - "update-all": "npm run update -w --root" - }, - "validate-branch-name": { - "errorMsg": "Please use a branch name that follows the pattern: (main|develop) or (feature|bugfix|release|hotfix|support|copilot|claude)/.\nUse git push --no-verify to bypass this check if necessary.", - "pattern": "^(main|develop){1}$|^(feature|bugfix|release|hotfix|support|copilot|claude)/.+$" - }, - "workspaces": [ - "src/*", - ".mcp" - ] + "lint-staged": { + "!(*schema|package-lock).json": [ + "json-normalize -c -w -a -i -t 4 -f local -l true" + ], + "!(templates/**/*|.agents/**|**/package-lock|package-lock).{js,jsx,ts,tsx,md,html,css,vue,yaml,yml,json}": [ + "npx --yes prettier --write" + ], + "*.php": [ + "composer lint" + ], + ".devcontainer/*": [ + "cp -u -t ./stubs/.devcontainer " + ], + "composer.json": [ + "composer validate --no-check-all --no-check-lock --no-check-publish" + ], + "package.json": [ + "npm pkg fix" + ] + }, + "optionalDependencies": { + "@tomgrv/devcontainer-features-common-utils": ">=5" + }, + "overrides": { + "uuid": "^11.0.0" + }, + "peerDependencies": { + "@commitlint/cli": "^19.2.2", + "@commitlint/config-conventional": "^19.2.2", + "@commitlint/core": "^19.2.2", + "@commitlint/cz-commitlint": "^19.2.0", + "commit-and-tag-version": "^12.4.1", + "commitizen": "^4.3.0", + "devmoji": "^2.3.0", + "git-precommit-checks": "3.1.0", + "lint-staged": "^15.2.2", + "prettier": "^3.2.5", + "prettier-plugin-sh": "*" + }, + "prettier": { + "insertPragma": true, + "overrides": [ + { + "files": "*.{yml,yaml}", + "options": { + "tabWidth": 2, + "useTabs": false + } + }, + { + "files": "*.json", + "options": { + "tabWidth": 4, + "useTabs": false + } + }, + { + "files": "*.yml", + "options": { + "tabWidth": 2, + "useTabs": false + } + } + ], + "plugins": [ + "prettier-plugin-sh" + ], + "semi": false, + "singleQuote": true, + "tabWidth": 4, + "trailingComma": "es5" + }, + "private": true, + "repository": { + "type": "git", + "url": "git+https://github.com/tomgrv/devcontainer-features.git" + }, + "scripts": { + "lint": "npx --yes lint-staged", + "prepare": "sh .husky/install.sh || echo 'Husky not installed in ' $(pwd)", + "release": "commit-and-tag-version --no-verify --", + "test": "bats -r tests src", + "update": "npm-check-updates -i -u", + "update-all": "npm run update -w --root" + }, + "validate-branch-name": { + "errorMsg": "Please use a branch name that follows the pattern: (main|develop) or (feature|bugfix|release|hotfix|support|copilot|claude)/.\nUse git push --no-verify to bypass this check if necessary.", + "pattern": "^(main|develop){1}$|^(feature|bugfix|release|hotfix|support|copilot|claude)/.+$" + }, + "workspaces": [ + "src/*", + ".mcp" + ] } diff --git a/skills-lock.json b/skills-lock.json index 9d01b04c..2c8d2e7b 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -23,7 +23,7 @@ "source": "JuliusBrussee/caveman", "sourceType": "github", "skillPath": "skills/caveman-compress/SKILL.md", - "computedHash": "810b9f31c05c3ef5d195eac4f76cd26674ba13f542d6ad5a0b16d5362b021614" + "computedHash": "9c8d35882a93e387a584cc4efaacb74433d379a2bd02e3e6f6ad4f7c30ff55ed" }, "caveman-discover": { "source": "JuliusBrussee/caveman", @@ -53,7 +53,7 @@ "source": "JuliusBrussee/caveman", "sourceType": "github", "skillPath": "skills/caveman-learn/SKILL.md", - "computedHash": "7af5a39ccea7b778581938662422ffce01df4271fb5403a63953794748210e93" + "computedHash": "df046a04f93e61061082652f0e1b44a3dd81709b35f384b0e243a4fb630a03fb" }, "caveman-manage": { "source": "JuliusBrussee/caveman", @@ -85,6 +85,12 @@ "skillPath": "skills/caveman-stats/SKILL.md", "computedHash": "cc9fcd21f2a30cff5596b3cbb197fe0f4b1b30b58084a864350853c217ad301a" }, + "harness": { + "source": "tomgrv/devcontainer-features", + "sourceType": "github", + "skillPath": ".agents/skills/harness/SKILL.md", + "computedHash": "611817a4992972fb5269521a949697c02d0f3641a23cb9052d75bdc558da08b3" + }, "investigate-first": { "source": "JuliusBrussee/caveman", "sourceType": "github", @@ -107,37 +113,37 @@ "source": "DietrichGebert/ponytail", "sourceType": "github", "skillPath": "skills/ponytail/SKILL.md", - "computedHash": "89689013408391ee226b9e465e74fd4be91eddc228801310d0c9beef80494305" + "computedHash": "061f887d9e3e735f85db3be3655a8936a00f24d5fb65ac4ab74a968a770fe342" }, "ponytail-audit": { "source": "DietrichGebert/ponytail", "sourceType": "github", "skillPath": "skills/ponytail-audit/SKILL.md", - "computedHash": "893ffdf2213c90e8e4da021cbbcf8cfae1f104ffc78bf31fde8b66977939aa0b" + "computedHash": "1d718f893dabc01caad0dacef86444b5b4b3e1e59409527d66e8b5dd0cd85ae0" }, "ponytail-debt": { "source": "DietrichGebert/ponytail", "sourceType": "github", "skillPath": "skills/ponytail-debt/SKILL.md", - "computedHash": "4876d5a952e9a6b75623cab785a93d1f34180cf43194ee07e4199b302581254f" + "computedHash": "f5d1d2ab891ff0a650a90b67235e487828e7d2a189de08a1ae073032de379407" }, "ponytail-gain": { "source": "DietrichGebert/ponytail", "sourceType": "github", "skillPath": "skills/ponytail-gain/SKILL.md", - "computedHash": "725571b5c159e6504e0a2b337d7e2a9917e22ab87809f182059c2f2562ec9657" + "computedHash": "fca2297cf7b5fd26692f6bd94d398b522e19fa81ad6e2aaebf1933575f7a6803" }, "ponytail-help": { "source": "DietrichGebert/ponytail", "sourceType": "github", "skillPath": "skills/ponytail-help/SKILL.md", - "computedHash": "e89570222f67eb50d7b9a2fadb8e0a106bad832ff2d9fd9d9841a79c58769127" + "computedHash": "4582025c3923072fb6c35b8cbd75adf4f7ce47df0eff43cc9a2a39808537fd61" }, "ponytail-review": { "source": "DietrichGebert/ponytail", "sourceType": "github", "skillPath": "skills/ponytail-review/SKILL.md", - "computedHash": "238fa04e4d2363fb3298fd346d287316021ed48eb8c3aa5fc94be576371a7a88" + "computedHash": "800dc0c86402978e12359f701b9e0c81ba4607a0cd0027f4596d0f835a67eadc" }, "safe-refactor": { "source": "JuliusBrussee/caveman",