feat(compile): recompile when a prompt modifier changes, and record which ran - #188
Merged
Conversation
ethanj
force-pushed
the
feat/prompt-modifier-fingerprint
branch
2 times, most recently
from
August 21, 2026 08:41
bba502a to
b1c5c04
Compare
…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
force-pushed
the
feat/prompt-modifier-fingerprint
branch
from
August 21, 2026 17:53
b1c5c04 to
dc8aae2
Compare
This was referenced Aug 21, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 Japaneseover 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. SettingLLMWIKI_OUTPUT_LANGbehaved the same way.The fix
The selected modifiers now travel in
state.jsonas a digest, and flipping one invalidates the pages it would have changed. Pages also carry apromptModifiersfrontmatter entry, surfaced per page in the JSON export:promptVersionnames the prompt implementation and is identical whether or not a modifier was active, so it could never separate two such pages.promptModifierPairsis 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
--langwhen 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 --stalesupplies achangeFilterand 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.markUnchangedPendingSourcesdemotes a promoted source back tounchangedwhen 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
--reviewhas 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.tswas already past the 400-line guideline before this change, so the promotion lives inprompt-modifiers.tsandindex.tsgrows by 12 lines — the call site and the flush.When #183 lands,
--no-sources-sectionbecomes one more entry inactivePromptModifiersand inherits the invalidation with no further work.