Skip to content

feat(compile): recompile when a prompt modifier changes, and record which ran - #188

Merged
ethanj merged 1 commit into
mainfrom
feat/prompt-modifier-fingerprint
Aug 21, 2026
Merged

feat(compile): recompile when a prompt modifier changes, and record which ran#188
ethanj merged 1 commit into
mainfrom
feat/prompt-modifier-fingerprint

Conversation

@ethanj

@ethanj ethanj commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Closes the first two items of #186. Items 3 and 4 are not included — they need files from #182 and #184, which are unmerged.

What was wrong

llmwiki compile --lang Japanese over an already-compiled project reported "Nothing to compile" and left every page in the previous language. Change detection classifies a source purely by the SHA-256 of its bytes, and a prompt modifier is not part of the source, so the pipeline short-circuited before any prompt was built. Setting LLMWIKI_OUTPUT_LANG behaved the same way.

The fix

The selected modifiers now travel in state.json as a digest, and flipping one invalidates the pages it would have changed. Pages also carry a promptModifiers frontmatter entry, surfaced per page in the JSON export: promptVersion names the prompt implementation and is identical whether or not a modifier was active, so it could never separate two such pages.

promptModifierPairs is the single canonical rendering that both the digest and the frontmatter stamp consume, so a page's recorded modifiers and the state digest cannot drift into disagreeing about what "the same selection" means.

The migration edge

An absent digest reads as "none selected", not as its own third state.

I tried the other reading first — absence means "no difference", so upgrading never costs a recompile — and it is wrong in a way worth recording. The no-op compile path never flushes state, so a project with nothing to compile would never record a first digest, and flipping a modifier on exactly the settled project this feature exists for would stay silent forever.

Reading absence as "none" costs an untouched project nothing: its first compile records the empty selection and finds no difference. It costs one recompile to a project already running under --lang when it upgrades, which is the same trade the embedding store already makes for an index predating fingerprints.

The digest is recorded at the single durable flush rather than at load, so a compile that dies mid-run leaves the previous digest on disk and the re-run still sees the difference.

Scoped runs must not record the selection

The digest is one global fact, so anything that narrows what a run recompiles can advance it past work that never happened, leaving a page current and permanently stale. Two such paths exist and both are handled.

refresh --stale supplies a changeFilter and recompiles a subset by design. It now flushes source state but leaves the digest alone, so the sources it filtered out keep the only signal that says they are stale. The pages it did refresh pay a second regeneration on the next full compile, which is the safe direction to be wrong in.

markUnchangedPendingSources demotes a promoted source back to unchanged when a pending review candidate carries the same source hash — and it ran on the line right after the promotion, so with any candidate present the feature was undone entirely and a modifier flip made zero model calls. Each candidate now records the digest it was generated under, and deduplication demotes only when the hash and the selection both match.

Comparing against the project's digest instead is not sufficient, and it fails in only one direction. Review mode never flushes state, so a project whose only compiles were --review has no recorded digest — and an absent digest means "none selected", which is exactly what clearing a modifier requests. Japanese → Spanish passes either way; Japanese → default does not.

Tests

11 unit cases for the set, digest and change test, plus four CLI integration cases: two that compile twice over byte-identical sources, and three covering the narrowing paths above (a pending candidate under a changed selection, the same transition in reverse, and a scoped refresh --stale).

Only the integration cases can detect any of this — every mutant is killed by them and by no unit test: neutering the promotion, never recording the digest, always recording it on a scoped run, and dedupe ignoring the selection.

Notes

src/compiler/index.ts was already past the 400-line guideline before this change, so the promotion lives in prompt-modifiers.ts and index.ts grows by 12 lines — the call site and the flush.

When #183 lands, --no-sources-section becomes one more entry in activePromptModifiers and inherits the invalidation with no further work.

@ethanj
ethanj force-pushed the feat/prompt-modifier-fingerprint branch 2 times, most recently from bba502a to b1c5c04 Compare August 21, 2026 08:41
…hich ran

Closes the first two items of #186.

A prompt modifier is a setting that changes what the page prompt ASKS FOR
without changing the committed prompt wording — today only the output language,
set by --lang or LLMWIKI_OUTPUT_LANG. `detectChanges` classifies a source purely
by the SHA-256 of its bytes, so `llmwiki compile --lang Japanese` over a settled
project reported "Nothing to compile" and left every page in the previous
language. The selection now travels in state.json and a flipped modifier
invalidates the pages it would have changed.

Pages also carry a `promptModifiers` frontmatter entry, surfaced per page in the
JSON export. PROMPT_VERSION names the prompt IMPLEMENTATION and is identical
whether or not a modifier was active, so it could not separate two such pages.

The digest is one GLOBAL fact, so anything that narrows what a run recompiles
can otherwise advance it past work that never happened, leaving a page current
and permanently stale. Two such paths exist and both are handled:

  - A SCOPED run (`refresh --stale`, which supplies a changeFilter) recompiles a
    subset by design and must not record the selection as true of the project.
    It flushes source state but leaves the digest, so the sources it filtered out
    keep the only signal that says they are stale. The refreshed pages pay a
    second regeneration on the next full compile — the safe direction.

  - `markUnchangedPendingSources` demotes a promoted source back to `unchanged`
    when a pending review candidate carries the same source hash, so with any
    candidate present a modifier flip made zero model calls. Each candidate now
    records the digest it was GENERATED under and dedup demotes only when the
    hash and the selection both match.

    Comparing against the PROJECT's digest instead is not sufficient, and the
    difference is only visible in one direction: review mode never flushes
    state, so a project whose only compiles were `--review` has no recorded
    digest, and an absent digest means "none selected" — exactly what clearing a
    modifier requests. Japanese to Spanish passes either way; Japanese to
    default does not.

An ABSENT digest reads as "none selected" rather than as its own third state.
Reading absence as "no difference" was tried first and is wrong: the no-op
compile path never flushes state, so a project with nothing to compile would
never record a first digest, and flipping a modifier on exactly the settled
project this exists for would stay silent forever. Reading it as "none" costs an
untouched project nothing and costs one recompile to a project already running
under --lang when it upgrades — the same trade the embedding store makes for an
index that predates fingerprints.

The digest is recorded at the single durable flush rather than at load, so a
compile that dies mid-run leaves the previous digest on disk and the re-run
still sees the difference.

`promptModifierPairs` is the one canonical rendering that both the digest and
the frontmatter stamp consume, so a page's recorded modifiers and the state
digest cannot disagree about what the same selection means.

Items 3 and 4 of #186 are not included: they need files from #182 and #184,
which are unmerged. When #183 lands, --no-sources-section becomes one more
entry in activePromptModifiers and inherits the invalidation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUdoRq1DJq23aJuhK9QK7X
@ethanj
ethanj force-pushed the feat/prompt-modifier-fingerprint branch from b1c5c04 to dc8aae2 Compare August 21, 2026 17:53
@ethanj
ethanj merged commit 3d931f8 into main Aug 21, 2026
3 checks passed
@ethanj
ethanj deleted the feat/prompt-modifier-fingerprint branch August 21, 2026 22:05
ethanj added a commit that referenced this pull request Aug 23, 2026
`finalizeWiki` took `scoped` as a fifth positional boolean, added when the
prompt-modifier fingerprint landed (#188). #169 wants a sixth in the same
shape, to skip the embedding refresh.

Two adjacent defaulted booleans is the wrong place to be. They are the same
type, so transposing them type-checks silently, and each one INVERTS a
behaviour on a path whose whole point is to do less than the default: a scoped
run that must not record the modifier selection as true of the project, and a
compile that must not embed. Swapping them produces a compile that does the
opposite of what the caller asked, with nothing to catch it.

They move into a named `FinalizeFlags` object. One flag was tolerable; a second
one arriving is the signal to name them rather than to add a parameter. The
next flag is then `flags.x` at the call site and impossible to transpose.

No behaviour change. #188's scoped control was mutation-tested through the new
shape at both the call site and the destructure, and each mutant is still
caught.
ethanj pushed a commit that referenced this pull request Aug 25, 2026
Picks up #170, rebased onto current main and finished against the review.
Partly answers #144.

`compile({ systemPolicy })` appends deployment-specific editorial or
publication guidance to the built-in compile prompts, for an SDK host that
needs it without forking the prompts. Additive rather than a replacement, and
placed before the source material so the whole instruction block still precedes
the content it describes. Blank or omitted leaves the prompt byte-identical.

It is ADVISORY. A policy makes a model more likely to follow a rule; it cannot
make it obey one, and nothing downstream verifies that it did. Anything that
must hold belongs in a lint rule or a trust gate, and the docs say so rather
than letting "policy" imply a boundary.

The policy is registered as a PROMPT MODIFIER, which is the substantive change
from #170. A policy alters what the prompt asks for without changing a source
byte, so without this a settled project reports "Nothing to compile" and keeps
content generated under a policy the operator has already replaced. Registering
it in activePromptModifiers means it inherits the whole mechanism from #188:
invalidation on change, candidate-aware deduplication so a pending review
candidate produced under one policy does not satisfy a run asking for another,
the scoped-refresh guard, and per-page provenance. Bumping PROMPT_VERSION alone
does not achieve this, because two policies share a version.

The policy lives in AsyncLocalStorage, matching quietScope and verboseScope in
utils/output.ts. The SDK documents that concurrent calls are fully isolated
with no global state, and the compile lock is per ROOT, so two callers
compiling different projects genuinely overlap in one process. A module
variable that saves and restores handles nesting and fails on interleaving:
measured, two overlapping runs observed ["Policy B", undefined] where they
should observe ["Policy A", "Policy B"] — one project reading another's policy
and the other losing its own. That reaches further than prompt text, since the
policy feeds the digest written to state.json, candidates and page provenance.

The DIGEST enters the modifier set, never the text, because that set is hashed
into state.json, stamped onto every page's frontmatter, and published through
the JSON export.

The prompt builders read the policy from run state rather than taking it as a
parameter, so their signatures are unchanged and the option is not threaded
through extraction, page rendering, the review pipeline and seed pages, none of
which otherwise need to know it exists.

PROMPT_VERSION advances to v2, and is now pinned by a test: nothing in the repo
asserted its value before, so a silent revert would mislabel every page
compiled afterwards.

Co-authored-by: TigerOfCountryYao <TigerOfCountryYao@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUdoRq1DJq23aJuhK9QK7X
ethanj pushed a commit that referenced this pull request Aug 25, 2026
Based on #170 by @TigerOfCountryYao, rebased onto current main and finished
against the review. Partly answers #144.

His design is what ships: the additive framing, its wording verbatim
("Additional system policy (follow in addition to every built-in instruction
above):"), the placement after the built-in instructions and before the source
material, blank treated as omitted so the default prompt stays byte-identical,
and the PROMPT_VERSION bump. The invalidation mechanism, the isolation and the
tests below are not his.

`compile({ systemPolicy })` appends deployment-specific editorial or
publication guidance to the built-in compile prompts, for an SDK host that
needs it without forking the prompts. Additive rather than a replacement, and
placed before the source material so the whole instruction block still precedes
the content it describes. Blank or omitted leaves the prompt byte-identical.

It is ADVISORY. A policy makes a model more likely to follow a rule; it cannot
make it obey one, and nothing downstream verifies that it did. Anything that
must hold belongs in a lint rule or a trust gate, and the docs say so rather
than letting "policy" imply a boundary.

The policy is registered as a PROMPT MODIFIER, which is the substantive change
from #170. A policy alters what the prompt asks for without changing a source
byte, so without this a settled project reports "Nothing to compile" and keeps
content generated under a policy the operator has already replaced. Registering
it in activePromptModifiers means it inherits the whole mechanism from #188:
invalidation on change, candidate-aware deduplication so a pending review
candidate produced under one policy does not satisfy a run asking for another,
the scoped-refresh guard, and per-page provenance. Bumping PROMPT_VERSION alone
does not achieve this, because two policies share a version.

The policy lives in AsyncLocalStorage, matching quietScope and verboseScope in
utils/output.ts. The SDK documents that concurrent calls are fully isolated
with no global state, and the compile lock is per ROOT, so two callers
compiling different projects genuinely overlap in one process. A module
variable that saves and restores handles nesting and fails on interleaving:
measured, two overlapping runs observed ["Policy B", undefined] where they
should observe ["Policy A", "Policy B"] — one project reading another's policy
and the other losing its own. That reaches further than prompt text, since the
policy feeds the digest written to state.json, candidates and page provenance.

The DIGEST enters the modifier set, never the text, because that set is hashed
into state.json, stamped onto every page's frontmatter, and published through
the JSON export.

The prompt builders read the policy from run state rather than taking it as a
parameter, so their signatures are unchanged and the option is not threaded
through extraction, page rendering, the review pipeline and seed pages, none of
which otherwise need to know it exists.

PROMPT_VERSION advances to v2, and is now pinned by a test: nothing in the repo
asserted its value before, so a silent revert would mislabel every page
compiled afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUdoRq1DJq23aJuhK9QK7X
ethanj added a commit that referenced this pull request Aug 25, 2026
Based on #170 by @TigerOfCountryYao, rebased onto current main and finished
against the review. Partly answers #144.

His design is what ships: the additive framing, its wording verbatim
("Additional system policy (follow in addition to every built-in instruction
above):"), the placement after the built-in instructions and before the source
material, blank treated as omitted so the default prompt stays byte-identical,
and the PROMPT_VERSION bump. The invalidation mechanism, the isolation and the
tests below are not his.

`compile({ systemPolicy })` appends deployment-specific editorial or
publication guidance to the built-in compile prompts, for an SDK host that
needs it without forking the prompts. Additive rather than a replacement, and
placed before the source material so the whole instruction block still precedes
the content it describes. Blank or omitted leaves the prompt byte-identical.

It is ADVISORY. A policy makes a model more likely to follow a rule; it cannot
make it obey one, and nothing downstream verifies that it did. Anything that
must hold belongs in a lint rule or a trust gate, and the docs say so rather
than letting "policy" imply a boundary.

The policy is registered as a PROMPT MODIFIER, which is the substantive change
from #170. A policy alters what the prompt asks for without changing a source
byte, so without this a settled project reports "Nothing to compile" and keeps
content generated under a policy the operator has already replaced. Registering
it in activePromptModifiers means it inherits the whole mechanism from #188:
invalidation on change, candidate-aware deduplication so a pending review
candidate produced under one policy does not satisfy a run asking for another,
the scoped-refresh guard, and per-page provenance. Bumping PROMPT_VERSION alone
does not achieve this, because two policies share a version.

The policy lives in AsyncLocalStorage, matching quietScope and verboseScope in
utils/output.ts. The SDK documents that concurrent calls are fully isolated
with no global state, and the compile lock is per ROOT, so two callers
compiling different projects genuinely overlap in one process. A module
variable that saves and restores handles nesting and fails on interleaving:
measured, two overlapping runs observed ["Policy B", undefined] where they
should observe ["Policy A", "Policy B"] — one project reading another's policy
and the other losing its own. That reaches further than prompt text, since the
policy feeds the digest written to state.json, candidates and page provenance.

The DIGEST enters the modifier set, never the text, because that set is hashed
into state.json, stamped onto every page's frontmatter, and published through
the JSON export.

The prompt builders read the policy from run state rather than taking it as a
parameter, so their signatures are unchanged and the option is not threaded
through extraction, page rendering, the review pipeline and seed pages, none of
which otherwise need to know it exists.

PROMPT_VERSION advances to v2, and is now pinned by a test: nothing in the repo
asserted its value before, so a silent revert would mislabel every page
compiled afterwards.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant