Skip to content

feat(hermit): prevent PROP-NNN frontmatter id collisions across concurrent / off-skill proposal creation #75

@hermit-scribe

Description

@hermit-scribe

Context

Surfaced 2026-05-15. While listing proposals, the operator caught two files claiming the same id: PROP-022 in their frontmatter:

  1. PROP-022-per-fleet-domain-brainstorm-skills-234500.md — created 2026-05-14 23:45 (canonical id: PROP-022-per-fleet-domain-brainstorm-skills-234500, correct form).
  2. PROP-022-tokens-alongside-usd-reporting-235748.md — created 2026-05-15 00:57 by a separate session (id: PROP-022, short form, written manually without invoking /proposal-create).

Two distinct failure modes combined to produce the collision:

  • Off-skill creation. The second proposal file was written directly with the Write tool rather than invoking /claude-code-hermit:proposal-create. The skill's "list existing PROPs, take max NNN, add 1" rule never fired, so the NNN was assigned by reading the proposal list captured earlier in the session — which predated the per-fleet-domain proposal. The skill's filename-collision guard (the a/b suffix from PROP-008) only triggers on same-second filename collisions, not NNN reuse across different slugs.
  • Short-form id. The frontmatter used id: PROP-022 rather than the canonical id: PROP-022-tokens-alongside-usd-reporting-235748. The canonical form is unique by construction (slug + HHMMSS); the short form is not. Even if the NNN had been unique, the short-form id violates the schema documented in proposal-create/SKILL.md step 67 ("the canonical ID PROP-NNN-<slug>-HHMMSS — equals the filename stem without .md"). Most existing PROPs (PROP-001 through PROP-013, PROP-018, PROP-019, PROP-020, PROP-021, PROP-022) use short-form ids, so this is a long-standing schema drift, not new with this incident.

Already resolved manually: the tokens proposal was renamed to PROP-023 and its frontmatter id updated to PROP-023-tokens-alongside-usd-reporting-235748.

Problem

Three concrete risks if this stays unaddressed:

  1. Off-skill proposal creation is the realistic path, not the exception. The model (or an operator) often writes a PROP file directly because the file is short and the skill prose isn't loaded. The skill's NNN-assignment rule lives in prose; it has zero enforcement teeth. Two parallel sessions or one inattentive session can collide.
  2. Short-form id is the de-facto convention even though the schema requires canonical form. Reading the on-disk proposals: most files use id: PROP-NNN not id: PROP-NNN-<slug>-HHMMSS. The two coexisting conventions confuse downstream consumers (proposal-list table, /proposal-act resolution algorithm, cortex Dataview views, metrics dedup). PROP-008 split into PROP-008-103800 and PROP-008 exactly because of this drift.
  3. Detection happens by eyeball. The operator caught this only because the proposal-list table showed two PROP-022 rows. Without that incidental surfacing, both proposals could've lived in parallel for weeks until one got accepted/dismissed and the other was treated as referring to the same decision.

Proposed Solution

Three coordinated layers — each cheap, each independently useful, together robust:

Layer A — next-prop-id.js helper script (eliminates eyeball NNN)

New script at plugins/claude-code-hermit/scripts/next-prop-id.js. Takes a slug and prints the next reserved PROP-NNN-<slug>-HHMMSS to stdout. Implementation:

  1. Glob .claude-code-hermit/proposals/PROP-*.md.
  2. Extract NNN from each filename (regex ^PROP-(\d+)), take max, +1, zero-pad to 3.
  3. Append current HHMMSS (timezone from config.json).
  4. Probe for filename collisions; append a/b/... if needed.
  5. Reserve atomically: create an empty <filename>.lock sentinel file with O_CREAT|O_EXCL (fails if it already exists). If the lock exists, increment HHMMSS by 1s and retry; cap retries at 3.
  6. Print canonical id to stdout.
  7. Caller writes the proposal file, then deletes the .lock sentinel.

This isn't a true cross-process lock (no flock for cross-host safety), but for the realistic concurrency level (operator + at most one hermit session at a time), it closes 95% of the race window. The remaining 5% (two processes racing for the same O_CREAT|O_EXCL) is bounded — one fails, increments, retries.

Update proposal-create/SKILL.md step 1 to:

  1. Run node ${CLAUDE_PLUGIN_ROOT}/scripts/next-prop-id.js <slug> and capture stdout as the canonical proposal id.

Update state-templates/CLAUDE-APPEND.md to add a "Proposal creation" note: Never write .claude-code-hermit/proposals/PROP-*.md files by hand. Always invoke /claude-code-hermit:proposal-create (or, in skill prose, run scripts/next-prop-id.js).

Layer B — validate-proposals.js integrity check (catches off-skill writes)

New script at plugins/claude-code-hermit/scripts/validate-proposals.js. Reads every PROP file's frontmatter; flags:

  1. NNN duplicates: two or more files share the same NNN prefix in their filename or frontmatter id.
  2. id ≠ filename stem: frontmatter id must equal filename without .md. Catches short-form ids (id: PROP-022 in a file named PROP-022-tokens-...md).
  3. id format violations: must match ^PROP-\d{3}-[a-z0-9-]+-\d{6}[a-z]?$.

Exit code 0 on clean, 1 on any violation; prints offending files + reason to stderr.

Wire as:

  • A new proposals_integrity check in hermit-doctor (becomes check release(claude-code-dev-hermit): v0.2.3 #9, between cost budget and proposal health).
  • A pre-write hook (PreToolUse on Write/Edit, matcher .claude-code-hermit/proposals/**): if the script returns 1 after the planned write, refuse the write and surface the error. (Approximation: today's enforce-deny-patterns hook is the closest pattern.) Optional — Layer A handles prevention; Layer B is detection.

Layer C — Migrate existing short-form ids to canonical (cleanup, not gating)

One-off: a script that scans all existing PROP files, computes canonical ids from the filename, and rewrites short-form id: PROP-NNN to id: PROP-NNN-<slug>-HHMMSS in frontmatter. Idempotent — no-op if already canonical.

Out of scope for the routine flow; one-time chore the operator runs after merging this PROP.

What's NOT in scope

  • True cross-host locking (overkill; the realistic concurrency is single-host).
  • Mutex via state/runtime.json (more complex than the sentinel-file approach; same race semantics).
  • Renaming filenames on collision detection (too destructive; flag and let operator decide).
  • Updating proposal-metrics.jsonl historical entries to canonical ids (history record; don't rewrite).

Impact

  • Effort: Layer A ~40 lines of JS + 1-line skill edit. Layer B ~60 lines of JS + 1-line doctor wiring. Layer C ~30 lines, one-time. Total ~2h.
  • Risk: Very low. Layer A only changes id assignment, which was already prose-defined. Layer B is read-only validation. Layer C runs once.
  • Benefit:
    • Eliminates the off-skill NNN reuse path (Layer A).
    • Surfaces drift at every hermit-doctor run (Layer B).
    • Aligns existing files with the documented schema (Layer C).
    • Closes the loop on PROP-008's "collision-safe proposal IDs" theme — that earlier PROP added the a/b filename suffix; this one prevents the more common NNN-reuse-across-slugs case it didn't cover.

Open Questions

  1. Should Layer A also write a stub file at reserve time, not just a .lock sentinel? Writing the actual file with empty body would guarantee ls-visibility for downstream max-NNN scans. Lean: yes, stub the file with frontmatter only, mark status: drafting; let the proposal-create skill fill in the body. Simpler than a separate .lock.
  2. Should /proposal-list reject or warn-and-show on duplicates? Lean: warn-and-show — the operator (or the doctor check) needs to see them to fix; silently rejecting risks hiding work.
  3. What about MP- micro-proposals? They use a different id scheme (MP-YYYYMMDD-N). Out of scope here; same theme worth a follow-up if reuse happens there too.

Filed via hermit-scribe · proposal=PROP-024 · session=null

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions