Skip to content

v1.9.0

Choose a tag to compare

@thettwe thettwe released this 18 May 06:30
· 12 commits to main since this release
80b7756

Added

  • Workspace-aware profiles (monorepo support)profiles/_schema.json gains workspaces[].profile, letting a single root profile assign different child profiles to each workspace path. Bootstrap, retrofit, docs, hooks, CI, and CLAUDE.md generation now honour per-workspace assignments instead of forcing one profile across a polyglot repo.
  • bin/resolve-workspace-configs.sh — merges detected workspace paths (from detect-stack.sh) with profile overrides (per-workspace profile, ci, documentation, extras) and emits a normalised WorkspaceConfigs JSON array consumed by every downstream subsystem. Covered by tests/bats/test-resolve-workspace-configs.bats (8 new cases).
  • schemas/workspace-configs.schema.json — new contract locking the per-workspace config shape, including optional profile, ci, and documentation overrides.
  • Per-workspace docs scaffoldingbin/scaffold-docs.sh --workspace-configs materialises workspace-local docs/ directories for workspaces whose assigned profile declares documentation.scaffold_types. Wired into bin/bootstrap.sh after step 4c (workspace resolution) so the scaffolder sees workspace assignments before writing.
  • Per-workspace CI matrixbin/gen-ci.sh emits a per-workspace lint/typecheck/test matrix when the resolved configs declare CI overrides. YAML-safe key sanitization keeps matrix keys path-traversal-free.
  • Per-workspace gitignore — bootstrap step 5b writes workspace-local .gitignore files from the workspace language template (jsts, python, dart, etc.) when extras.gitignore=true on the workspace config.
  • Workspace guidance in generated CLAUDE.mdbin/gen-claudemd.sh appends a per-workspace section listing each workspace, its assigned profile, and a one-line responsibility hint, so agents reading CLAUDE.md know where work belongs.
  • workspace_suggestions[] in profile-suggestion outputbin/suggest-profile.sh extends ProfileSuggestion with per-workspace profile recommendations for monorepos, surfacing the best-matching profile per workspace stack. Schema: schemas/profile-suggestion.schema.json (optional field, backward-compat).
  • bin/detect-doc-conformance.sh — new read-only scanner that compares docs in a repo against canonical archetype paths and proposes reorganisation moves. Emits a JSON array of {source, target, category, confidence} entries consumed by compute-drift.sh and reorganize-docs.sh. 284 LOC, 0 mutations.
  • bin/reorganize-docs.sh — preview-by-default move executor. Reads an approved subset of conformance proposals and executes via git mv (in-repo) or mv. Preview-before-mutate: without --apply, the script previews planned moves and exits without touching the filesystem. Hardened against path traversal, symlink-mediated escape, target overwrites, and mkdir/cat failures.
  • Doc misplacement in drift reportsbin/compute-drift.sh now invokes detect-doc-conformance.sh and surfaces results as a misplaced[] array in the DriftReport. Both doctor (read-only) and retrofit (remediation) report it; retrofit offers to invoke reorganize-docs. Conformance script failures are now logged via nyann::warn rather than silently swallowed.
  • bin/detect-stack.sh::detect_doc_hints — new doc-archetype hint emitter consumed by conformance detection. Identifies common doc patterns (single README, docs/ directory, archetype split) so reorganization proposals respect the user's existing layout style.
  • bin/detect-mcp-docs.sh multi-source + vault discovery — settings are now read from multiple sources (settings_sources[]) and Obsidian vaults under the project root are auto-detected (discoverable_vaults[]). New --project-path flag lets callers scope discovery to a workspace subtree rather than $PWD.

Changed

  • bin/bootstrap.sh step ordering — workspace config resolution (step 4c) now runs before doc scaffolding (step 5). The previous order left scaffold-docs unable to see per-workspace documentation settings; its --workspace-configs block was effectively unreachable. The renamed step "5b: per-workspace gitignore" reflects the cleaned sequence (4c → 5 → 5b → 5c).
  • bin/release.sh rollback failures now warn rather than silently swallowing — release.sh previously routed every rollback path through || true, so a partial-tag-creation or partial-commit failure during rollback was invisible. Failed rollback steps now log nyann::warn with the specific subsystem, letting the operator finish the cleanup by hand.
  • bin/gen-ci.sh marker matching tightened from substring to grep -Fxq (anchored, full-line) — a workspace named lint no longer falsely matches the legacy single-job lint marker comment in a hand-edited ci.yml.
  • bin/scaffold-glossary.sh IFS refactorIFS=',' array splitting replaced inline tr/awk pipelines for parsing language and term lists, eliminating subtle word-splitting bugs with terms containing whitespace.

Fixes

  • bin/reorganize-docs.sh defaults to preview, not mutate (audit-flagged hard block) — the initial implementation took --dry-run as opt-in and silently git mv-ed files by default. That violates nyann's preview-before-mutate non-negotiable. The script now previews unless --apply (or --yes) is passed; callers in bootstrap-project and retrofit skills updated to pass --apply after user confirmation.
  • bin/reorganize-docs.sh mkdir failure is now caught — an unchecked mkdir -p "$(dirname "$target_abs")" could leave a move un-executed while reporting success (errexit aborted the iteration mid-row, never incrementing fail_count). Wrapped with explicit failure handling.
  • bin/reorganize-docs.sh malformed/unreadable moves files now fail loudlycat and jq length failures used to leave move_count as null and report "no moves to execute" instead of dying. Both call sites now nyann::die with the actual path on failure.
  • bin/compute-drift.sh surfaces conformance detection errorsdetect-doc-conformance.sh failures used to be swallowed via || _conformance_json='[]' with 2>/dev/null, masking arg errors, jq failures, and missing scripts. Failures now emit nyann::warn before the [] fallback (drift report still completes — observability without aborting).
  • schemas/drift-report.schema.json and schemas/profile-suggestion.schema.json stay backward-compatible — initial v1.9.0 work added misplaced and workspace_suggestions to the schemas' required arrays, which rejected v1.8.0-compatible fixtures and externally-cached reports. Both fields are now optional (producers still emit them). documentation.claude_md.additionalProperties reverted from false to true so forward-compat fields don't break old consumers.
  • bin/learn-profile.sh orphan --workspace-profiles flag removed — the flag was parsed but never invoked by any caller, skill, or command. Dropped to reduce surface area.
  • bin/detect-stack.sh dead local saved_ifs — saving/restoring IFS inside a pipe-subshell body had no effect on the parent shell. Removed the dead save/restore pair.
  • bin/release.sh script-format bump warning no longer fires during dry-run preview — the planned command is already surfaced in the bump-plan JSON, so the extra warn just added noise. The warn still fires at apply time as a final security alarm before the script executes.
  • bin/detect-mcp-docs.sh settings precedence corrected (Codex adversarial review #1) — the multi-source merge appended .claude/settings.local.json before .claude/settings.json, and the merge takes "later sources win", so the checked-in project file silently overrode the user's local override. Order is now global → project settings.json → project settings.local.json, matching Claude Code's own precedence. A regression test guards against re-inverting.
  • Per-workspace bootstrap writes now pre-enumerated into plan.writes[] (Codex adversarial review #2) — bin/plan-bootstrap.sh resolves workspace configs for monorepo stacks and emits per-workspace .gitignore + doc-scaffold paths into plan.writes[] upfront. The operator sees every workspace file in the preview before confirming, and bin/bootstrap.sh's existing snapshot loop now picks workspace writes up automatically — so undo-bootstrap can reverse a monorepo bootstrap cleanly. _br_category_for learned to classify nested paths (packages/*/.gitignoregitignore, packages/*/docs/*docs) so undo's scope filter works.
  • bin/resolve-workspace-configs.sh forwards inline documentation and ci overrides — the exact-path and wildcard override branches dropped documentation and ci fields from inline workspace configs (only named-profile workspaces preserved them). Workspace doc scaffolding silently no-op'd as a result. Both fields now flow through all three override branches.
  • bin/scaffold-docs.sh renders workspace docs with workspace stack context (Codex adversarial review #3) — the v1.9.0 workspace-doc loop reused the root repo's primary_language/framework/package_manager/project_name template variables, so a Python workspace under a TypeScript root got TS-flavoured architecture/PRD copy. Each workspace iteration now runs inside a subshell that shadows those variables with the workspace's resolved stack metadata. Root-doc rendering is unaffected (subshell scope).
  • bin/scaffold-docs.sh --allowed-writes runtime preview-gate — scaffold-docs.sh's workspace-doc loop now optionally accepts a newline-delimited list of plan-approved write paths. When provided (bootstrap.sh builds it from plan.writes[] and passes it in), every workspace doc target is checked against the set before writing; targets absent from the list are skipped with a warn. Defends against planner/executor drift: if plan-bootstrap.sh and resolve-workspace-configs.sh ever disagree on which scaffolds belong in the plan, the gap is caught at write time instead of materialising files behind the operator's preview. Back-compatible: when --allowed-writes is omitted (direct callers), the loop behaves as before.
  • bin/resolve-workspace-configs.sh named-profile ci/documentation override merging — the named-profile branch initialised resolved_ci/resolved_documentation from the loaded profile, but the wildcard and exact-override layers only merged hooks/extras/owner — ci/documentation overrides on named-profile workspaces were silently dropped, making {"profile":"react-vite","ci":{"enabled":false}} impossible. Both override layers now deep-merge ci/documentation against the named-profile base using the same $base * $override jq pattern as hooks/extras. Profile schema extended to formally allow ci and documentation in workspace overrides (with restricted property sets so workspace-specific keys like lint/typecheck/test and scaffold_types are validated).
  • bin/resolve-workspace-configs.sh routes named profile loading through bin/load-profile.sh — previously load_named_profile() concatenated the workspace-supplied profile name into a filesystem path and cat'd the result. Bypassed nyann::valid_profile_name validation and validate-profile.sh schema checks; a crafted workspace profile: "../../etc/passwd" would have read arbitrary .json files. The function now (a) rejects names that don't match the canonical profile-name regex, and (b) delegates the actual load to load-profile.sh so the workspace named-profile path goes through the same validation/migration/source-resolution pipeline as every other profile read.
  • bin/gen-ci.sh workspace matrix installs pnpm and Bun toolchains — the single-stack TypeScript template already invokes pnpm/action-setup when needed; the per-workspace matrix workflow regressed and only set up Node, so a pnpm workspace would bootstrap green and immediately ship a CI workflow that failed on first run with "pnpm: command not found". The matrix now emits pnpm/action-setup@v4 and oven-sh/setup-bun@v2 steps conditional on a new matrix.package-manager field threaded into each include entry.
  • .github/workflows/community-marketplace-reminder.yml workflow_dispatch latest-release fallback — the input description said "Leave blank to use the latest release", but the version resolver only checked inputs.version and github.event.release.tag_name (the latter is empty on manual dispatch), so a blank manual run aborted with "no version resolved". The resolver now falls back to gh release list --limit 1 --json tagName when both upstream sources are empty, matching the documented UX.
  • bin/detect-mcp-docs.sh no longer scans $HOME/Documents for Obsidian vaults (user-reported privacy fix) — vault discovery is now strictly scoped to --project-path. The previous implementation enumerated .obsidian directories under $HOME/Documents on every bootstrap run, leaking personal vault paths (e.g. /Users/<user>/Documents/JournalVault) into the resulting MCPDocTargets JSON. That JSON flows into skill output, drift reports, and — in monorepo bootstrap flows — boot-record manifests committed to the repo by default. Not a classical exploit (read-only, scope-bounded find), but a real scope violation: nyann's documented surface is the project directory, never $HOME. The discovery-from-home feature is dropped entirely; vaults outside the project tree need to be wired in via a configured MCP server before nyann knows about them. Regression test in tests/bats/test-detect-mcp-multi-source.bats stages a fake $HOME with a vault and asserts the bootstrap output never contains it.
  • bin/boot-record.sh records the repo basename instead of the absolute target path (privacy audit follow-up) — manifest.json is committed by default under memory/.nyann/bootstraps/<timestamp>/, so writing "target": "/Users/<author>/Works/<repo>" leaked the original author's username and filesystem layout to anyone who pulled a bootstrap PR. The field is informational (v1.8.0 already stopped comparing it against the runtime target for portability), so storing just basename($target) is a strict improvement. Schema description and regression test updated to lock the contract: manifest.target must equal the repo basename and must not contain /, $HOME, or any other path-like content. Surfaced by a follow-up privacy audit after the $HOME/Documents fix above.

Security fixes

  • bin/gen-ci.sh validates every matrix scalar to prevent GitHub Actions YAML injection (security audit — Codex adversarial review, critical) — the existing _yaml_safe filter rejected quotes/newlines in ws_version/install/lint/typecheck/test command strings, but ws_lang, ws_pm, and the new *-run booleans were emitted into the matrix block without validation. A hostile workspace-configs.json (from a malicious team-source profile or a compromised PR) could embed newlines/quotes in .primary_language, .package_manager, or .ci.{enabled,lint,typecheck,test} to splice attacker-controlled keys into the generated workflow — the committed ci-workspaces.yml would then execute arbitrary commands on the repo's next CI run with the GitHub token in scope. Fix: enforce a strict alphanumeric+_- grammar on ws_lang and ws_pm, and require *-run booleans to be exactly true or false. Hostile values now get nyann::warn + skip the workspace. Three new bats regression tests in test-gen-ci-workspace-matrix.bats stage each injection vector and assert the generated workflow contains no payload.
  • bin/scaffold-docs.sh blocks symlink-mediated escape via intermediate directories (security audit — Codex adversarial review, critical) — write_if_missing rejects leaf symlinks but mkdir -p happily follows symlinks at intermediate components. A repo with a pre-placed symlink at packages/<ws>/docs/decisions/etc/ (or any other directory outside the target) would redirect the workspace doc scaffold's README.md / ADR-template.md / .gitkeep writes outside the target tree. The pre-existing path_under_target check on ws_doc_dir itself wasn't enough — it ran before the descendant mkdir -p. Fix: a new _ws_safe_mkdir helper walks every path component from target_root to the destination dir, refuses any existing symlinked ancestor, then re-verifies the canonical path stays under target after creation. Every workspace doc write now goes through this helper. Regression test in test-scaffold-docs-workspace-context.bats pre-places a symlinked descendant and asserts the script refuses without writing anything to the decoy target.
  • bin/bootstrap.sh scaffold-docs gate recognizes workspace-nested paths — the docs_in_plan jq filter previously matched only root-level docs/ and memory/ entries. A monorepo whose root profile declared no docs but whose workspace profiles did would surface workspace doc writes in the preview, then skip scaffold-docs.sh entirely at execution time — a direct preview-vs-execute mismatch. The filter now also matches */docs/... and */memory/... via test("(^|/)docs/").
  • bin/bootstrap.sh workspace .gitignore writes gated on plan.writes[] — step 5b used to iterate ws_configs_file and run gitignore-combiner.sh for every workspace with extras.gitignore=true, with no plan-membership check. A buggy or older plan that omitted those paths would leak workspace writes behind the preview. Each workspace .gitignore is now skipped + warned unless its exact path appears in plan.writes[].
  • bin/bootstrap.sh defensive jq null-coalesce for stack readspl=$(jq -r '.primary_language' …) and sl=$(jq -r '.secondary_languages | join(",")' …) would abort with "Cannot iterate over null" on a stack.json missing those fields. Both now use // "unknown" / // [] fallbacks.
  • bin/gen-ci.sh honors per-workspace ci.enabled/lint/typecheck/test flags — the multi-workspace matrix builder previously parsed .ci but only consumed version fields. A workspace with ci.enabled:false still got a job; ci.lint:false still ran Lint; no typecheck step existed at all even when requested. The builder now (a) skips disabled workspaces, (b) emits lint-run/typecheck-run/test-run booleans into each matrix include entry, (c) adds a Type check step keyed on matrix.typecheck-run, with per-language typecheck commands (tsc/mypy/go vet/cargo check/dart analyze). NB: the boolean defaults use jq 'if has(X) then .X else true end' rather than // true because the latter coalesces both null AND false — silently inverting explicit false settings was the worst-case bug to leave latent.
  • bin/gen-ci.sh no-config sentinels avoid embedded single quotes — the workspace YAML-safety filter rejects values containing ', and the previous echo 'no lint configured' sentinel silently caused languages without a profile-declared lint hook (e.g. python without ruff) to be dropped from the matrix entirely. Sentinels are now quote-free.

CI + tests

  • .github/workflows/security-scan.yml — new SAST workflow running alongside ci.yml. Semgrep (p/bash + p/github-actions) scans bin/, hooks/, templates/, and .github/ on every push + PR + weekly cron; CodeQL with the actions language pack runs on PR + cron only (its actions-language footprint is small enough that per-dev-push wouldn't pay for the ~3 min CI cost). templates/ is deliberately included: those scripts get installed into every nyann-bootstrapped user repo, so a command-injection bug there propagates to every downstream consumer — highest-leverage surface in the whole repo. Findings upload as SARIF to the Security tab; Semgrep is non-blocking during baseline triage. All third-party actions are commit-SHA-pinned (codeql-action 458d36d… resolved from v3); the CodeQL job uses the default query pack (the actions-language extended pack is noisier than the compiled-language equivalents — tighten once baseline is clean).
  • tests/bats/test-fuzz-validators.bats — 27-case adversarial sweep over _lib.sh's validators (path_under_target, valid_git_url, valid_git_ref, valid_profile_name, redact_url). Covers deep ../ chains, ..-mixed-with-existing-components, sibling-prefix collisions (repo vs repo-evil), nested symlink chains, self-referential loops, literal %2e%2e/spaces/unicode segments, very long paths; ext:: transport variants (lowercase + uppercase to lock in case-sensitive matching), --upload-pack= option injection, http:// MITM, adjacent non-git schemes (javascript/data/ftp/mailto/vbscript/rsync); credential redaction across every accepted scheme. Pins the existing helpers' behavior so future refactors of _lib.sh can't silently widen the attack surface. Brings the total bats suite to 1147.
  • README.md badge / blurb corrected: test count 1044 → 1147 and schema count 47 → 49.

Deferred

  • Orchestrator extraction (release.sh, gh-integration.sh, detect-stack.sh) — these three scripts are 49KB / 41KB / 51KB respectively, past the size where bash review reliably catches edge cases. Splitting each into smaller subsystems (e.g. detect-stack/{node,python,go,…}.sh, release/{plan,write-changelog,commit,tag,rollback}.sh) would reduce review surface and let compute-drift.sh-style focused testing extend to each piece. Not in this release — the refactor is multi-day and touches the most-tested orchestrators in the repo; tracked here so the decision and rationale isn't lost.

Release-process changes (no runtime impact)

  • .github/workflows/community-marketplace-reminder.yml — new workflow that opens a tracking issue on every published release. The Claude Code community marketplace (anthropics/claude-plugins-community) is a read-only mirror that pins each plugin to a specific source.sha (~99.8% of all 1,715 listed plugins use SHA pinning); PRs against the mirror are auto-closed, and the SHA only advances when Anthropic's review pipeline approves a fresh "New submission" — there is no "update existing plugin" path in the UI. Without an explicit reminder it's easy to ship a tag, get a green CI run, and never file the next submission — leaving community-install users stuck on the prior version (as happened for v1.2.0 through v1.8.0, all of which the community marketplace never picked up). The action queries the upstream mirror for the currently-pinned SHA, pre-fills a GitHub compare URL between the pin and the new tag, and posts a 5-step checklist linking to the in-app submission portals at claude.ai/settings/plugins and platform.claude.com/plugins/submit. Idempotent — won't open duplicates if the workflow re-runs for the same version. The issue body also notes that releases can be skipped (one submission supersedes the previous SHA pin), so the maintainer doesn't need to feel obligated to file every patch.
  • docs/RELEASING.md post-release verification now contrasts the two install paths explicitly. @nyann-plugins (direct, this repo's marketplace.json) updates as soon as the tag pushes; @claude-community (curated mirror) requires portal re-submission and a pipeline pass. The same RELEASING.md step also corrects the @nyann token mismatch — the marketplace name has been nyann-plugins since v1.0.0, but the docs said nyann.
  • README.md install instructions corrected to @nyann-plugins (matches .claude-plugin/marketplace.json's actual name field). Adds a note that the community path can lag behind because the upstream mirror is SHA-pinned and synced nightly; users who want immediate updates should use the direct path.