Skip to content

VibeTags v1.4.0

Choose a tag to compare

@PIsberg PIsberg released this 08 Oct 18:53
d20e187

[1.4.0] - 2026-10-08

Breaking, in a minor release. 1.4.0 stops writing every output that
1.3.5 and later deprecated (#641, #664 to #677, #845), each with a compiler warning naming the file
and its replacement. The warning said the next major version; the removal ships in 1.4.0
instead, by the owner's decision. A build that never saw that warning is unaffected. See Removed.

Removed

  • Four deprecated platform outputs are no longer written (#645). Each was deprecated in 1.3.5;
    this is the removal that warning announced. Their keys are gone from the opt-in list, so an
    existing file is neither regenerated nor warned about: it is left byte-identical and stops
    tracking the annotations. Move any hand-written content to the replacement and delete the file.

    • gemini_instructions.md, replaced by GEMINI.md (Gemini CLI) or .gemini/styleguide.md
      (Gemini Code Assist). No Google product documents reading it; a last look before removal
      (GitHub code search of the google-gemini and googleapis organisations, and a web search)
      found no vendor source either.
    • .cody/config.json and .codyignore, replaced by AGENTS.md, which Amp reads. Sourcegraph
      ended Cody Free and Pro on 23 July 2025, and its docs describe neither file.
    • .supermavenignore, replaced by .cursorignore, which Cursor Tab reads. Supermaven announced
      its sunset on 21 November 2025.
    • .clinerules as a single file, replaced by the .clinerules/ directory, which VibeTags already
      writes with an always-loaded +vibetags-safety.md. Cline's current docs describe only the
      directory. A leftover file activates nothing, and vibetags init --platforms cline_granular
      refuses to replace it.

    CodyRenderer is deleted with the Platform constants GEMINI, CODY, CODY_IGNORE,
    SUPERMAVEN_IGNORE and CLINE, and vibetags init --list no longer offers the five keys. The
    README counts drop to 43 platforms and 60 config files. examples/basic, examples/multimodule
    and examples/gradle-multimodule lose the fixtures, and examples/basic opts into the
    .clinerules/ directory instead, so ExampleOptInCoverageTest needs no exemption for it.
    RemovedPlatformOutputsTest pins the removal; all 8 of its cases fail against the 1.3.8
    processor.

  • The other seventeen deprecated outputs are no longer written (#720), on the same terms. The
    replacement each warning named is in docs/PLATFORMS.md, "Outputs removed in 1.4.0".

    • .void/rules.md (Void is archived and read .voidrules), .mentatconfig.json (Mentat read
      .mentat_config.json), sweep.yaml (Sweep is now a JetBrains assistant), .plandex.yaml
      (Plandex never read it), .pearai/rules/, .ghostcoderignore, .doubleignore,
      .piecesignore and .ai/rules/ (no tool documents them).
    • .claudeignore, .copilotignore and .antigravityignore: none of the three vendors documents
      the file. Claude Code uses Read deny rules, Copilot a Content exclusion setting, Antigravity
      read_file Deny rules or .gitignore.
    • .idx/airules.md and .amazonq/rules/, whose products end on 22 March 2027 and 30 April 2027.
      The owner chose to remove them in 1.4.0 rather than hold them to a later major version, as the
      1.3.5 warning promised: use Antigravity's .agents/rules/ and Kiro's .kiro/steering/, which
      VibeTags writes.
    • .interpreter/profiles/vibetags.yaml and ellipsis.yaml, which their tools no longer read, and
      .zencoder/rules/, which Zencoder removed in May 2026.

    With no deprecated output left, DeprecatedServices is deleted, and with it the deprecation
    warning, the platform.deprecated log event and vibetags init --list's [deprecated] marker.
    MentatRenderer, SweepRenderer, PlandexRenderer, InterpreterRenderer and
    EllipsisRenderer are deleted with their 17 Platform constants, and so are the two merge
    shapes only they used: JsonRulesMerge (Mentat's JSON) and YamlMergeShape's keyed buckets
    (Plandex). The prose formatter arms that .coderabbit.yaml, .pr_agent.toml, .roomodes,
    .gemini/styleguide.md and Greptile share were INTERPRETER's and are CODERABBIT's now; every
    one of those files is byte-identical. The README counts drop to 29 platforms, 47 config files and
    16 scoped-rule directories; both reactors go from 53 active services to 38.
    examples/multimodule-indexed opts into .aiexclude in place of .claudeignore and
    .copilotignore. RemovedPlatformOutputsTest covers all 22 outputs; its 19 new cases fail
    against the #645 commit.

Added

  • A hand-written .pr_agent.toml gets the guardrails without losing its settings (#933). The
    file is now merged the way greptile.json is (#639): VibeTags owns a delimited span inside the
    extra_instructions strings of [pr_reviewer] and [pr_code_suggestions], adds a table or key
    the file lacks, and keeps every other byte. A document it cannot merge without guessing is left
    alone with a warning naming why; the log records write.skip reason= with the merge's reason
    (duplicate-table, not-multiline-string, unclosed-span and the rest). Until now such a file
    was skipped outright, and before that overwritten. A [ line inside a multi-line string or a
    multi-line array is a value, not a table, so it neither ends a table early nor gets the key added
    a second time, and a quoted name (["pr_reviewer"], 'extra_instructions') is the same table
    or key as the bare one; a UTF-8 byte order mark in front of the first header does not hide it.
    TomlValueSpansTest (11 cases) and
    aHandWrittenPrAgentConfig_keepsItsSettingsAndGainsTheGuardrails cover it; the latter failed before
    the change.
  • vibetags-cli/README.md. The CLI's flags, exit codes and limits were spread over a USAGE.md
    section and the usage text in Main. The new README puts them in one place, with examples for
    each command and check. The output it shows comes from running the CLI against scratch
    projects. Linked from the README, the docs index and the repository layout notes.

Changed

  • A release puts 84 files on Maven Central instead of 150 (#863). Central allows an
    organization 1,167 files and 7 releases a month and rate-limits one that stays over, and
    September 2026 was over both. The publishing plugin's default sent SHA-256 and SHA-512 beside
    the MD5 and SHA-1 Central requires, and CycloneDX wrote each SBOM as XML as well as JSON. The
    central-publish profile now sets <checksums>required</checksums> and every published module
    writes -cyclonedx.json only; anyone fetching the -cyclonedx.xml classifier will find no new
    versions of it. Measured on a local bundle of vibetags-annotations built with the upload
    pointed at a closed port: 30 files before, 15 after, 36 and 20 once CI signs them.
    CentralPublishingBudgetTest pins both settings. docs/RELEASING.md and the release skill now
    count the month's tags before a release, with a cadence of at most 3 a month.
  • .aiexclude is written beside GEMINI.md (#645). It needed gemini_instructions.md or an
    active AGENTS.md beside it, so a Gemini user who followed the deprecation notice to GEMINI.md
    would have lost .aiexclude regeneration without a word. The orphan warning that suggests
    creating .aiexclude follows the same pairing, so a project with GEMINI.md, @AIIgnore or
    @AILocked, and no .aiexclude now gets that warning.
  • Gemini's section wording is registered under GEMINI_MD (#645). It lived under the removed
    GEMINI platform, which GEMINI.md reached as an alias through Platform.rendersAs(). No
    platform is an alias now; the seam stays for the next one. GEMINI.md prints the same bytes.
    )
  • Test counts in the docs re-measured. docs/architecture/testing.md carried a 0.7.x-era
    per-class table and totals of 1484 and "724+" tests; it now points at docs/TESTS.md, the
    maintained map. The tier table in TESTS.md is re-measured on 2026-10-06: 2655 tests in 208
    classes for mvn test, 3625 in 320 for mvn test -Pe2e, against 957 and 1546 on 2026-08-10.
  • Three more of this repository's own classes carry @AIContext. Each states a fact that
    lives in another file or another build, so the class cannot show it: SourceDigest (an input
    that shapes output but is not an option, opt-in or .vibetags-* config must be hashed, and a
    test that compiles once never reaches the early exit to notice), TransitiveManifest (its JSON
    is read out of JARs already on Maven Central by other processor versions) and
    EnforcementBaseline (consumers commit the file, and GuardrailEnforcer answers an
    unrecognised format with a warning and a pass, so dropping format 1 turns enforcement off on
    upgrade). @AICore was tried first and put the always-loaded block in CLAUDE.md at 8,213
    bytes against its 7,000 budget (#752). The facts matter when one of these files is edited,
    which is when its scoped rule loads, so the block grows by 34 bytes instead.
  • PMD 7.27.0 -> 7.28.0 (#889). 7.28 adds OnDemandImport to the best-practices category,
    which pmd-ruleset.xml imports whole, and it fired 5 times: three wildcard imports in the
    processor (AIGuardrailProcessor, FormatterRegistry, PlatformDescriptors) and the JMH
    wildcard in both load-tests benchmarks. They are explicit imports now and the rule stays live,
    since the rest of src/main already follows it. A new formatter or renderer therefore needs its
    import in the registry that lists it; the compiler says so if it is missing. The dependabot PR
    failed 10 jobs on the processor's 3 findings and never reached the 2 in load-tests.
  • Dependencies: ArchUnit 1.5.0 -> 1.5.1 (#890), async-test-lib 1.12.2 -> 1.12.4 (#894, #897),
    gradle/actions/wrapper-validation 6.3.0 -> 6.4.0 (#893), anthropics/claude-code-action
    1.0.235 -> 1.0.239 (#896), and the evals' pinned @anthropic-ai/claude-code 2.1.283 -> 2.1.286
    (#895). #890's red Gradle jobs were Maven Central answering 403 to the new ArchUnit POM, not
    the bump.

Fixed

  • A reactor root's Markdown aggregate states the scoped-rules index once (#930). Each module
    rendered its own ## Scoped Rules Index heading and its 378-byte note, and the merge stacked them,
    so they repeated once per module: 20% of blindbean's GEMINI.md and 24% of codekarta's. The merge
    now writes the heading and note once, after the module blocks, with every module's lines under
    them. It does so only when every module's section has the shape the renderer emits and the notes
    agree; otherwise, as with two processor versions in one reactor, the stacked form is kept. The
    sidecar format is unchanged. examples/multimodule-indexed's copilot-instructions.md loses its
    second copy. aReactorRootStatesTheIndexPreambleOnce failed before the change.
  • The scoped-rules index names a role file once per package (#931). An element routed onto a
    role file by .vibetags-roles kept a line of its own with the file repeated:
    async-test-lib's root GEMINI.md had 83 such lines, 8,744 bytes, pointing at 5 files. Elements
    of one package in one role file now share a line,
    <elements in="a.b" rules=".claude/rules/api.md">C, D</elements> in CLAUDE.md and
    - `a.b`: `C`, `D` → `.gemini/rules/api.md` in the Markdown aggregates. A role-file entry
    for a single element keeps its one-element form, which is shorter. examples/all-tiers changes
    accordingly. elementsSharingARoleFile_shareOneIndexLine failed before the change.
  • vibetags doctor checks a reactor module by module (#929). It read only the root build file,
    so a Maven reactor whose root only manages vibetags-processor and whose modules declare it was
    told the annotations "will not compile" and exited 1, while a processor that was only managed
    counted as wired. Doctor now follows the root's <modules> (profile-scoped and nested ones
    included) or the Gradle settings file's include(...), and checks each module whose sources
    import the annotations, against its own build file and what it inherits from the root. Each
    unwired module is named; a module with no annotations needs nothing. Four DoctorCommandTest
    cases failed before the change; codekarta, the consumer in the issue, now reports 6 use the annotations, all wired and exits 0.
  • A nested type's scoped rule loads for the file its code is in. The glob was built from the
    type's own simple name, so Ledger.Entry got **/Entry.java: no such file exists, and the rule
    never loaded when Ledger.java was opened, or an unrelated Entry.java elsewhere loaded it. A
    member of a nested type files under that type and had the same glob. The glob now names the
    outermost enclosing type's file, **/Ledger.java. The file a nested type is in reaches the build
    fingerprint, so moving Entry into a file of its own regenerates the glob; for every other
    element the fingerprint is unchanged. NestedTypeRuleGlobTest failed before the change.
  • vibetags doctor reads a Gradle include that spans lines. Only the line holding include
    was read, so include( with one project per line below it listed no module and doctor fell back
    to the root-only check above, and a Groovy include 'core', continued on the next line lost
    every project after the first. The arguments are now read to the closing parenthesis, or across
    every line ending in a comma. Two DoctorCommandTest cases failed before the change.
  • vibetags doctor reads a Gradle multi-project with no root build file. The settings file
    alone lists the projects and each configures itself, but doctor looked only for
    pom.xml or build.gradle[.kts] at the root, reported "no build file found" and exited 1
    without reading a module. A root settings.gradle[.kts] is now the build file when there is no
    other. gradleReactorWithNoRootBuildFile_readsEachProject failed before the change.
  • Upgrading a pre-marker file keeps the hand-written text below a long legacy block (#936). The
    end of an XML-shaped legacy block was looked for only in the first 2,000 characters after its
    header, so a block longer than that (a dozen guardrails) read as running to the end of the file,
    and everything below it was dropped. The block now ends at its first </project_guardrails>,
    wherever it is, plus the <rule> lines a block closes with; a block without one keeps the
    bounded search. aLongLegacyBlock_keepsTheHandWrittenSectionBelowIt failed before the change.
  • The orphan sweep looks past a UTF-8 byte order mark, as the writer already did. An editor that
    saves UTF-8 with a BOM puts U+FEFF in front of the file. In a rule file with no front matter it
    kept the first-line start marker from owning its line, so the sweep took the file for not ours and
    a removed guardrail stayed in it; in one with front matter the block went, but the header left
    behind no longer opened the file, so a rule file holding nothing but its globs was kept. The BOM
    is set aside while the file is judged and written back with what remains.
    deletesFile_whenAByteOrderMarkPrecedesTheMarkers and
    aRuleFileSavedWithAByteOrderMark_isStillSweptWhenItsAnnotationGoes failed before the change.
  • A hand-written .pr_agent.toml or .codex/config.toml is no longer overwritten. TOML has
    nowhere for a marker line, so VibeTags writes both files whole, and it replaced whatever was there:
    a team's PR-Agent settings the first time .pr_agent.toml counted as an opt-in, and a project's
    Codex configuration (model, MCP servers) the first time AGENTS.md was managed, which writes
    .codex/config.toml implicitly. Each is now replaced only when it is empty or carries the
    generated header; otherwise it is left exactly as it is, with a warning that says why it carries no
    guardrails and how to hand it over. The log records write.skip reason=hand-written-whole-file.
    aHandWrittenCodexConfig_isNotOverwritten and aHandWrittenPrAgentConfig_isNotOverwritten
    failed before the change.
  • A test-compile with no annotations no longer empties the ignore files. Ignore files
    (.cursorignore, .aiderignore and the rest) were rewritten from every round, even one that
    found no annotation. In a single-module Maven or Gradle build, the test-compile round sees only
    the test sources; when they carry no annotation it rendered an empty exclusion list and wrote it
    over the main round's, so every mvn test or mvn package left the ignore files with an empty
    block. A reactor module with no annotations, compiled after the one that has them, did the same
    to the shared root file. Now an ignore file is rewritten from an empty round only when the
    content is the merge of every module's sidecar, which is what still retires the last
    @AIIgnore of a reactor. anUnannotatedTestRoundKeepsTheMainSourcesIgnoreGlobs and
    anUnannotatedReactorModule_leavesTheSharedIgnoreFileAlone failed before the change;
    removingTheLastIgnoreInAReactor_clearsItFromTheSharedIgnoreFile pins the case the narrower rule
    keeps, and fails if the ignore term is dropped outright.
  • A module leaving the reactor no longer deletes the hand-written text in its rule files. The
    departed module's granular rule files were deleted by name, so a note the developer had added
    below the markers went with them, the one thing invariant 2 says never happens. The orphan sweep
    already removed only the generated block; the departure now does the same, deleting the file
    only when nothing of the developer's is left, and leaving a same-named file with no markers
    alone. moduleRemovedFromTheReactor_keepsTheHandWrittenTextInItsRuleFile failed before the
    change.
  • A rule file whose generated block could not be removed is retried by the next build. When the
    orphan sweep takes a guardrail out of a granular rule file that also holds the developer's own
    text, it rewrites the file instead of deleting it. A failed rewrite (a file an editor holds open
    on Windows, a read-only directory) was swallowed, and the cache entry still vouched for the file,
    so every later unchanged build short-circuited and the removed guardrail stayed in the file. The
    failure is now a warning, and the entry is marked failed so the next build retries, as a failed
    delete already was (#867). aRuleFileThatCouldNotBeRewritten_isScrubbedByTheNextBuild failed
    before the change.
  • vibetags doctor no longer says the annotations "will not compile" when they arrive through
    the processor.
    The processor's pom declares vibetags-annotations as a compile dependency, so a
    build with vibetags-processor as a Maven <dependency> (scope provided or compile) or a
    Gradle compileOnly/implementation/api dependency compiles the @AI* annotations without
    naming them. Doctor only looked for the string vibetags-annotations in the build file, so it
    reported a finding and exited 1 for four of the five consumer repositories in the regression
    sweep, all of which compile. It now prints annotations dep: yes (through the vibetags-processor dependency). A processor reached only through annotationProcessorPaths or Gradle's
    annotationProcessor, or with a test or runtime scope, is still a finding. Four
    DoctorCommandTest cases; the two healthy ones failed before the change.
  • A field or method annotated @AIIgnore no longer becomes a file glob (#926). Every
    exclusion file (.cursorignore, .aiexclude, Greptile's ignorePatterns and the rest) wrote
    it as **/<memberName>.java: a glob for a file that does not exist, or for an unrelated file
    that happens to share the name. examples/multimodule-indexed carried
    **/cachedExpiryEpochDay.java for a private transient long. Only a type contributes a glob
    now, as @AILocked already did for .aiexclude; the member is still excluded in every prose
    output that can name it. A module whose only @AIIgnore is on a member contributes nothing to
    an ignore file, rather than a header with no globs under it. MemberLevelAIIgnoreGlobTest
    failed 2 of 2 before the change.
  • A nested type annotated @AIIgnore or @AILocked no longer becomes a file glob either. It
    was written as **/<NestedName>.java, a file that does not exist, while its code is in the
    outermost type's file: an unrelated file of that name was hidden and the code meant to be was
    not. Only a top-level type contributes a glob now; the nested type is still excluded or locked
    in every prose output. Hiding the whole outer file instead would hide code nobody marked.
    aNestedTypeIsNotWrittenAsAFileGlob failed before the change, for both annotations.
  • A module that gave up waiting for the generation lock released it for other processes on
    Linux (#923).
    Every waiter opened its own channel on .vibetags-generate.lock and polled
    tryLock(). The JDK takes fcntl locks on Linux, which belong to the process, so when a waiter
    timed out or was interrupted and closed its channel, the holder's lock was gone while the holder
    still reported it held. A second build on the same root, a Gradle daemon beside a Maven run,
    could then generate concurrently, which is the interleaving #908 added the lock to prevent.
    .vibetags-baseline.lock used the same poll. Waiters in one JVM now queue at an in-JVM gate
    before any of them opens a channel, so only the thread about to hold the lock has the file open.
    The gate is an interned string's monitor and a system property, because each processor
    classloader has its own copy of the class. Windows locks per handle and never had the bug.
    GenerationLockTest starts a second JVM after a waiter gives up and asserts it cannot take the
    lock; on Linux JDK 21 it failed before the fix.
  • vibetags doctor judged markers differently from the writer (#919). It flagged a file when
    one marker literal appeared and the other did not. The writer only counts a start marker that
    owns its line outside a code fence, closed by the first end line after it. So doctor passed an
    end above its start, and a real block that lost its end next to a fenced example of the pair,
    both of which the next build repairs by discarding text or refuses; and it failed a CI gate on a
    marker quoted inside a sentence, which the writer ignores. Doctor now asks the writer through
    GuardrailFileWriter.hasBrokenMarkers, built on the same search the write path uses. Three
    DoctorCommandTest cases were red before the change.
  • vibetags doctor --context counted hand-written text as generated (#919). The report found
    the block with the same literal search, so a marker quoted in prose above the block made it
    count everything from the quote down: 207 B reported as generated in a test file whose block
    was 102 B. It now uses the writer's indexOfBlockStart and indexOfMarkerLine, and reports the
    one block the writer manages.
  • One unlistable directory turned off doctor's Groovy and Kotlin checks (#922). The project
    was walked once per file type, and the first directory doctor could not list ended each walk,
    so a root-owned directory such as a database volume left the checks with whatever came before
    it, often nothing, and was reported once per file type. The project is now walked once; the
    directory is one finding and every readable source is checked.
  • vibetags doctor --classpath aborted on an unlistable directory in a class directory
    (#921).
    Files.walk threw it as an UncheckedIOException the IOException handler did not
    catch, so doctor printed a bare AccessDeniedException and stopped after the marker line, with
    no Kotlin check and no result. The class directory is now walked file by file: an unreadable
    directory or class file is one more finding and the rest still counts.
  • vibetags doctor --help and vibetags init --help failed with exit 2 (#920). Only a
    --help in first position was recognised; after a command it was a stray argument. --help
    and -h now print usage and exit 0 wherever they appear.
  • vibetags init --platforms mishandled blank and repeated entries (#920). ,cursor or
    claude,,cursor failed the whole command with unknown platform key(s): and no key named;
    claude,claude reported the key as both created and already active. Blank entries are now
    skipped, repeats collapse to one, and a list with no key left is the usual "Nothing created"
    usage error.
  • A round skipped as unchanged flushed the shared write cache without the generation lock
    (#916).
    When another processor generates sources after the first round was skipped, the round
    clears its recorded source digest and flushes .vibetags-cache. That flush re-reads and rewrites
    the root file, and the merge on flush (#907) is not a lock: racing a sibling's generation flush,
    either side's changes could be lost, the clear included, leaving a digest that vouches for the
    skip. In a reactor that flush now takes .vibetags-generate.lock like generation's do. The branch
    is rare, so the ordinary early exit waits on nothing. ParallelReactorGenerationTest holds the
    lock while a rebuild takes that branch through a wrapper processor that generates a source; the
    rebuild finished while the lock was held before the change and waits for it after.
  • The vibetags-self-check pre-commit hook exited 127 on Windows outside Git Bash (#905).
    Run from PowerShell or cmd, pre-commit found bash through PATH, which there is the WSL
    launcher in WindowsApps. WSL bash read the backslashed script path as escapes, so the hook
    failed before tools/self-check.sh started. The hook now runs the script as a git ! alias,
    which Git runs with its own shell from the repository root on every platform. Verified from
    PowerShell and from Git Bash: both run the 25 s regeneration and pass.
  • Two copies of the processor in one JVM could write through the same temp file. The root
    .vibetags-baseline and .vibetags-cache are written to <name>.<pid>-<n>.tmp and renamed
    into place, with <n> from a static counter. A parallel reactor that loads the processor once
    per module has one counter per copy, each starting at zero, so two modules wrote
    .vibetags-baseline.<pid>-1.tmp at once: one rename moved bytes its writer never produced and
    the other failed with NoSuchFileException. The name now carries a random tag drawn once per
    class load, <name>.<pid>-<tag>-<n>.tmp. Found by the two-classloader test written for #911.
  • Enforcement baselines were recorded unlocked when a sibling module loaded the processor in
    another classloader (#911).
    EnforcementBaseline.update() took .vibetags-baseline.lock with
    lock(). A file lock belongs to the whole JVM, and the per-root monitor in front of it only to
    one copy of the class, so under mvn -T or Gradle --parallel the second module got
    OverlappingFileLockException and merged with no lock: the #554 lost update again. It now polls
    tryLock() through GenerationLock, which waits in both cases. Like generation, a wait over two
    minutes proceeds unlocked instead of hanging the build; before, a lock held by another process
    was waited on indefinitely. Two new EnforcementBaselineTest cases failed before the change: one
    holds the lock outside the monitor and sees update() write anyway, and one records from two
    classloaders at once and lost an approval in round 0.
  • In a parallel reactor the last write-cache flush dropped every sibling's cache entries
    (#907).
    Each module's processor loads .vibetags-cache before any of them generates, and
    WriteCache.flush() wrote that whole loaded view back, so the last module to flush erased the
    entries, fingerprints, sidecar stamp and source digests its siblings had flushed since. Nothing
    was wrong in the output, but the early exit and the per-file fast path missed on the build after
    a parallel one. A flush now re-reads the file and replaces only what this instance changed: the
    keys it recorded or removed, its own module section, and the root stamp if it set one. An entry
    is still kept exactly as its writer recorded it, so the merge cannot pair one writer's hash with
    another's file attributes. WriteCacheTest drives two instances loaded before either flushes;
    two of its three new cases failed before the change. Measured on examples/multimodule (#915):
    the second of two clean verify builds under -T 4 took the cache fast path for 285 of 544
    file writes before the change and 544 of 544 after, the same as a serial build; the other 259
    fell back to read-and-compare. Identical in three runs each.
  • A parallel reactor could drop a module's region from the root files (#908). Under mvn -T
    or Gradle --parallel, a module that read the sidecars before a sibling saved one could write
    CLAUDE.md and the other root files last, from a merge without that sibling, and the region
    stayed missing until the next build. Module rounds now generate under an exclusive lock on
    .vibetags-generate.lock at the root (empty; gitignore it), taken around generateFiles() in
    process() so the locked step order is untouched. The lock polls tryLock() because a second
    module in the same JVM gets OverlappingFileLockException rather than a wait, and it never
    fails a build: an unusable lock or a wait over two minutes proceeds unlocked and logs
    generate.lock.skip reason=. The root round, single-module builds and a root with no opt-in
    take no lock and create no file. Lock files no longer count as configuration in the early-exit
    key, or the lock appearing would have cost every module its early exit on the next build
    (SourceDigestEarlyExitEndToEndTest caught it). ParallelReactorGenerationTest holds one module between its sidecar read and its write
    through a test-only seam, since the window is milliseconds: red 3 of 3 before, green 3 of 3
    after, and red again with the lock planted to give up on OverlappingFileLockException.
  • In a parallel reactor the write cache could vouch for another module's bytes. Every
    module of a mvn -T or Gradle --parallel build writes the root files through its own
    WriteCache over the one .vibetags-cache. The writer stat'ed the file after moving its bytes
    into place, and a file it found already current after reading it, so a sibling's write landing
    in between paired this module's hash with the sibling's size and mtime. The cache then reported
    the file unchanged while it held other content, and the write that would have fixed it was
    skipped. How that reaches a user is reasoned, not reproduced end to end: a later build that
    renders an earlier body again, such as after a module is removed, keeps the departed module's
    region. A write now records the staging file's attributes, which a rename keeps. A file found
    current is recorded only when a stat before the read and one after it agree on size, full
    mtime and file key (the inode on Linux and macOS); otherwise it is marked for re-checking next
    build. The second stat guards an interleaving reasoned from the code, a replacement before the
    read followed by a third write of the same size in the same millisecond; no test has made it
    fail. WriteCacheCrossInstanceAsyncTest reproduces the defect with 8 writers on one file:
    reverting the write-path fix or the found-current fix fails it in 3 of 3 runs on Windows.
  • .aiexclude no longer gets an empty header block for each module with nothing to exclude.
    AiExcludeRenderer always emitted its three-line header, and the reactor merge wraps every
    non-blank contribution, so each module and each source set without an excludable type added a
    header with no globs under it. This repository's own .aiexclude held two empty copies (main and
    test rounds) and nothing else, and three of the six headers in examples/multimodule had no
    globs under them. IgnoreFileRenderer stopped doing this for the other ignore files in #328;
    .aiexclude has its own renderer and was missed. It now decides on the globs it emitted rather
    than on the model, because a locked member is real but cannot be named by a glob. Pinned by
    AiExcludeEmptyContributionEndToEndTest, which failed on the old renderer with the header-only
    output.
  • A guardrail file saved as UTF-16 now says so instead of Input length = 1 (#878). Windows
    PowerShell 5.1's echo "" > CLAUDE.md writes UTF-16 LE with a byte order mark (checked: the
    file starts FF FE). VibeTags reads and writes UTF-8, so every build warned
    Failed to write AI rules file: ... - Input length = 1 and the file never got its guardrails,
    with nothing saying why. The warning now names the encoding (it is saved as UTF-16LE, or
    it is not valid UTF-8 for a legacy single-byte file) and says to re-save the file as UTF-8,
    and the log records write.skip reason=not-utf8 encoding=. The file is still left
    byte-for-byte unchanged: transcoding it would change its encoding under the user. A coding
    failure that does not come from the file keeps the plain message.
  • Instruction Evals and the Inquisitor run on a Claude subscription token as well as an API key
    (#632).
    Both workflows looked only for ANTHROPIC_API_KEY, so a repository holding a
    CLAUDE_CODE_OAUTH_TOKEN secret (from claude setup-token) skipped both gates for good. The
    preflights now accept either secret and the model steps receive both; an unset one is dropped
    before the eval harness starts. ModelCredentialGateTest pins the two halves together. The
    workflow header and evals/README.md also quoted a default run of 15 and 12 sessions; with 6
    tasks it is 18.