diff --git a/plugins/provenance/.claude-plugin/plugin.json b/plugins/provenance/.claude-plugin/plugin.json index 17a4b1fa3..38e53d39d 100644 --- a/plugins/provenance/.claude-plugin/plugin.json +++ b/plugins/provenance/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "provenance", - "version": "0.2.0", + "version": "0.3.2", "description": "Finds prose in tracked markdown that restates content an external source owns (vendor docs, blogs, articles) without adequate attribution, confirms the source, and refactors the copy into a pointer, a citation, or a dated stamped record. Documentation provenance, not software supply chain. Nomination and judgment are LLM work; the scripts do only reasoning-free work (corpus scoping, breadcrumb extraction, stamp expiry, fingerprint compare of two concrete texts). Read-only audit by default; explicit fix and sweep actions apply dispositions behind a semantic-diff guard and live pointer verification. Findings conform to the detector-findings convention.", "author": { "name": "Melodic Software", diff --git a/plugins/provenance/CHANGELOG.md b/plugins/provenance/CHANGELOG.md index a0a817a19..7b9390bfe 100644 --- a/plugins/provenance/CHANGELOG.md +++ b/plugins/provenance/CHANGELOG.md @@ -1,5 +1,209 @@ # Changelog +## [0.3.2] + +### Fixed + +- **The window fix landed in one of the two scripts that share the definition.** + `extract-breadcrumbs.sh:is_stamp` still sliced the keyword window at exactly its length after + 0.3.1 fixed `check-stamps.sh:keyword_window`, so the two disagreed about what a stamp candidate + is while the audit flow passes the extractor's output to nomination. Measured across every file + the 0.3.1 fix newly parsed, **five** were short a stamp line the extractor should have + inventoried: `docs/CLOUD-SESSIONS.md` (3 against 4), + `docs/conventions/loop-lane/README.md` (4 against 5), + `docs/topics/fresh-eyes-checkpoint-audit/design/design-resolution.md` (1 against 2), + `plugins/context-guard/CHANGELOG.md` (4 against 5), and + `plugins/session-flow/CHANGELOG.md` (7 against 8). All five agree now. + + An earlier draft of this entry said three, because it sampled five of the seven affected files + and reported the differences it happened to catch as the total. The number here comes from + sweeping all seven against both versions of the extractor. + + `docs/CLOUD-SESSIONS.md:320` is the worked case, and it is worse than the 0.3.1 one rather than + a repeat of it. Its date begins at offset 60 of the 60-character window, so the cut left a bare + `2` and **no** form matched — not even the bare-year fallback that at least kept the 0.3.1 case + visible in the declined bucket. The line did not decline; it left the inventory entirely, which + is the quieter failure of the two. + + The same slack-and-start-boundary rule now applies in both: slice `wlen + 9`, require every + match to begin at or before `wlen`. A regression test pins the real corpus line, with a negative + control at offset 64 confirming the added slack does not admit a date that starts outside the + window. + + One property of `is_stamp` is worth recording, because it masked this and will mask the next + attempt to reproduce it: the function rescans from each keyword in turn, so a line carrying a + second keyword beside its date (an `as-of` immediately before it, say) matches there regardless + of what the first window truncated. A fixture written to exercise the boundary must carry + exactly one keyword, or it passes against the unfixed script and proves nothing. Two fixtures + written the other way did exactly that here, and a third placed the date by eye rather than by + measurement; only lifting a real corpus line verbatim produced a genuine red. + + That sentence is also why this paragraph names no literal date. An earlier draft of it quoted + one as an example of the shape, and the corpus run then reported this changelog as carrying an + expired stamp, one day over. Prose *about* stamp syntax is indistinguishable from a stamp to a + mechanical detector, and this file is inside the corpus it documents. + +- **The review dispatch could not execute rubric v3 either.** 0.3.0 gave the judge the containing + file and left the reviewer, which runs when `accuracy.review_agents > 0`, holding only the + passage, the source text, and the quoted grades. Its job includes checking the C3 grade and + whether a carve-out was missed; C3 is graded across the file and carve-outs 1, 4 and 5 are + file-level. A reviewer without the file either declines the check or waves through an + unsupported C3 PASS — and review is the last stage before fix eligibility, so waving one through + is what puts an unsupported finding in reach of an automatic edit. The review prompt now carries + `LOCAL FILE:` on the same terms as the judge prompt. + +## [0.3.1] + +### Fixed + +- **A conforming ISO stamp was declined as a bare year, and a declined stamp is never + expiry-checked.** `check-stamps.sh` sliced the keyword window at exactly its length (60 + characters, 30 after `read`), which cut through any date that started inside the window but ended + past it. At `docs/upstream/aihero-course.md:127` the window ended mid-date: `as-of 2026-08-17` + was read as `2026-08-`, the ISO test failed on the fragment, and the bare-year fallback then + matched the `2026` it left behind. Declining routes the line into the reported `declined` bucket + and skips it, so the one thing the script exists to do, compare the date against the currency + window, never ran on a date that parses perfectly well, and the output said only that the stamp + date went unparsed. + + The window is now a distance from the keyword rather than a cut through the text. The slice + carries nine more characters, one short of the longest form the tests match, and every form must + begin at or before the window length, so the added slack lets a date finish without admitting one + that starts outside the window. + + Corpus effect at `--as-of 2026-08-28` over 1,352 tracked files, stated as the delta because that + is the part that stays true: **+7 candidates, +7 parsed, declined unchanged** (month-name and + bare-year trading 2, as the two below flip), **expiry findings unchanged at 0**. Absolutes + measured at `fb11cf6a` are 538 to 545 candidates and 493 to 500 parsed, against 45 declined. + + **Those absolutes will not reproduce at another commit, and the reason is worth more than the + numbers.** This changelog is inside the corpus it measures, so each paragraph added here creates + new stamp candidates and moves the totals. The figures first published in this entry were taken + before the entry itself was written and were already stale by the time it shipped: a smaller, + quieter instance of exactly the staleness 0.2.1 was written to correct. The delta is the durable + claim; an absolute needs the commit it was taken at, and even then only holds there. + + Two of the seven newly parsed stamps had been declined as bare years + (`docs/upstream/aihero-course.md:127`, + `plugins/context-guard/reference/cloud-headless-capture.md:78`); the other five were not detected + as candidates at all, because truncation left nothing date-shaped in the window. None of the seven + is expired — the oldest is 40 days, and the oldest parsed stamp anywhere in the corpus is 142 days + against a 180-day window — so no lapsed stamp had been hidden by this. + + Two new declines appear, both instances of the separate `may` false positive, where the month-name + test reads the ordinary English word as a month name: `plugins/planning/skills/interview/SKILL.md` + at line 117 and `plugins/repo-hygiene/skills/clean/context/git-branch-cleanup.md` at line 42. In + each the word starts inside the window (at offset 60 and 59) and the old slice cut it after one + character, so the same truncation that hid the ISO dates had been hiding these. That defect is + untouched here, and reproduces identically on the previous script: it over-reports into the + declined bucket, which is the direction that stays visible to a reader, and is left for its own + fix. + + Patch rather than minor: no flag, no output shape and no configuration changes. The counts move + because the existing expiry check now reaches stamps it had been dropping. + +## [0.3.0] + +### Changed + +- **Rubric version 3: version 2 never said at which scope C3 is graded.** Applying the rubric to + a real corpus passage surfaced it. Two readers reached the same verdict on + `plugins/dometrain/skills/grounding/SKILL.md:50-64` at `d7e391da` (containment 0.589, a + 142-token matched span against `Dometrain/mcp@master` fetched 2026-08-28) and disagreed on which + scope produced it. Under version 2 both readings were available and they resolve in opposite + directions: grade C3 and C4 both at the file and a majority-adapted file *that carries adequate + file-level attribution* clears twice; grade both at the span and a well-attributed derived file + stands every time. + + Version 3 states it: **C3 is graded outward across the whole file, C4 on the passage.** What C3 + tests is whether the attribution's declared scope matches the derivation's — file-scope + attribution discharges C3 when the derivation is file-wide, and does not when one lift sits + inside otherwise-original material, where the header understates and the reader misallocates. + This is a substantive addition, and version 2's "a bare link at the bottom of a long file does + not attribute a specific paragraph in the middle of it" cuts against it. **C4's half is only + written down**: its worked examples and its closing replacement test were already + passage-scoped, so nothing about C4 changes. + + The rejected reading is worth recording because it is the one a judge reaches for: "the + attribution exists and is complete." That is not the test. It would let a single lift into an + otherwise-original file escape C3 on the strength of a header line about something else. + +- **The judge dispatch could not execute the new rule, and now can.** `reference/nomination.md` + handed each judge the local passage, the fetched source, and the rubric — never the containing + file. A C3 graded across the whole file is unanswerable from that, and both the rubric and the + judge prompt instruct UNKNOWN when the text to quote is absent, so a *conforming* judge under + version 3 would have graded C3 UNKNOWN on every candidate, stopping every verdict and routing + every run to the human. The motivating case proves it: the attribution that clears it sits about + 35 lines above the passage. The dispatch now supplies `LOCAL FILE:` and says which criteria are + graded against which input. Blindness in this panel means blind to the pipeline's own suspicion + — the fingerprint numbers, the nomination's reasoning, the other judges — never blind to the + material a criterion is defined over. The lens-diversity stance that read for "whether the + attribution present already discharges the obligation" was pointing judges at the reading + version 3 rejects, and now reads for scope match. + + Carve-outs 1, 4 and 5 are file-level judgments too, and were under-supplied by the + passage-only dispatch before this change. That gap predates version 3; it is closed by the same + fix. + +- **The measurement version 2 stands on does not carry forward.** This file's own rule is that a + criterion change invalidates any measurement pinned to the prior version, and version 3 adds a + scope-match test to C3 that can decide a case either way. **The golden set must be re-scored + against version 3 before any precision figure is cited against it**, and no class becomes + fix-eligible on a measurement pinned to a superseded rubric. Version 2 took the one exception to + that rule on the argument that it changed no criterion's substance; version 3 cannot make that + argument and does not try. + + Said plainly so the figures are not left under a cloud they do not deserve: **no current golden + case appears to turn on the scope question.** Seven carry no attribution anywhere, one is + declined at a carve-out before grading, one fails C1, and the single case with attribution is a + lift inside an otherwise-original file, which resolves identically at either scope. The re-score + is expected to reproduce 8 tp / 0 fp / 0 fn / 2 tn. It is still required, because the rule keys + on a criterion changing rather than on a recorded case flipping — and inventing a second, weaker + exception ("substantive change, but the set does not happen to exercise it") to save a ten-case + re-score that costs nothing is the bad trade. + +## [0.2.1] + +### Fixed + +- **The Phase 6 corpus baseline is stale: it reports Phase 3 figures.** The 0.2.0 entry records + 1,347 tracked files after carve-outs, 525 stamp candidates, 482 parsed, 43 declined, 0 expired, + oldest parsed stamp 2026-04-08. All six reproduce exactly at `33dccc59` + ("corpus, breadcrumb, and stamp scripts, Phase 3 part 1" — the commit that introduces + `list-corpus.sh`), clean tree, running the scripts as they existed there. They were then carried + into the Phase 6 paragraph several commits later without re-measuring, so a paragraph presenting + itself as the Phase 6 measurement reports a Phase 3 one. + + The current baseline, at `619199ee` with `--as-of 2026-08-28`: **1,352** tracked markdown files + after carve-outs (1,395 considered, 43 declined at path level), **535** stamp candidates, + **491** parsed, **44** declined at stamp level (20 month-name forms, 24 bare years), **0** + expired at the 180-day default, oldest parsed stamp 2026-04-08 at + `plugins/work-items/skills/track/actions/add.md:104`, and 9 findings at a 60-day window. + `list-corpus`'s path-level `declined: 43` and `check-stamps`'s stamp-level `declined: 44` count + different populations and are not an inconsistency. + + **Two of those figures are date-relative and expire**, which is why the as-of date is pinned + beside the commit rather than left implicit. `0 expired at the 180-day default` holds only + until 2026-10-05 on the current oldest stamp, and `9 findings at a 60-day window` moves daily. + `check-stamps.sh --as-of` reproduces both at the recorded date. A baseline recorded without one + is the same staleness this entry corrects, one turn later. + + **The delta is not what a first reading of it suggested.** It is not `main` moving across #3467 + to #3469: those three contribute **+1 in total**, one added file in #3468. #3467 adds 20 + markdown files and contributes **zero**, because every one lands under `evals/fixtures/golden/` + inside the excluded tree — which is why it raises `considered` by 20 and the fixture decline + from 3 to 23 while leaving the corpus untouched. The rest of the gap is the four months of + corpus growth between Phase 3 and now. Separately, `.claude/provenance.json` is first tracked in + `d7e391da`, so the `excluded_paths` layer postdates the figures in the 0.2.0 paragraph. + + **A note on how the wrong diagnosis was nearly recorded instead**, because the method matters + more than this particular number. A first pass replayed the carve-out filter across 60 commits + reachable from `main`, found 1,347 at none of them, and concluded the figure came from no commit + at all. The originating commits sit on the pre-squash build branch, which the squash-merge made + unreachable from `main`; the reflog held them throughout. A history replay bounded at a squash + boundary cannot answer "does this number come from a commit", and reporting that it can converts + a missing sample into a false negative. + ## [0.2.0] ### Added @@ -325,6 +529,9 @@ corpus genuinely carries month-name and bare-year stamp forms, and a parser that guessed at them would manufacture findings against dates nobody wrote down. + **These are Phase 3 figures, carried into this Phase 6 paragraph without re-measuring.** They + reproduce exactly at `33dccc59`. See 0.2.1 above for the current baseline and its as-of date. + - **The fingerprint module, the plugin's one pure library.** Word 5-shingles, containment, Jaccard, and contiguous matched spans between a local passage and an already-fetched source, behind a thin CLI. It decides nothing: it reports lexical overlap and diff --git a/plugins/provenance/skills/audit/reference/nomination.md b/plugins/provenance/skills/audit/reference/nomination.md index e4d716f7b..8ec304b6d 100644 --- a/plugins/provenance/skills/audit/reference/nomination.md +++ b/plugins/provenance/skills/audit/reference/nomination.md @@ -67,8 +67,22 @@ candidate URL lists. evidence per criterion. **Blindness is required, and it is what makes sampling mean anything.** Each judge sees the -local passage, the fetched source text, and the rubric. No judge sees: the nomination's stated -suspicion, the fingerprint numbers, another judge's verdict, or how many judges are running. +local passage, the fetched source text, **the containing file**, and the rubric. No judge sees: +the nomination's stated suspicion, the fingerprint numbers, another judge's verdict, or how many +judges are running. + +**The containing file is an input, not an oversight, and rubric version 3 is why.** C1, C2 and +C4 are graded on the passage. **C3 is graded outward across the whole file** — it asks whether +the attribution's declared scope matches the derivation's, which cannot be answered from a +passage alone. Carve-outs 1, 4 and 5 are file-level judgments too ("the surface's purpose", +"could this passage have been written without the source in hand"), and were already +under-supplied by a passage-only dispatch. Withholding the file does not make the panel more +blind in the sense that matters; it makes a conforming judge grade C3 UNKNOWN on every +candidate, because the rubric and the prompt below both require a quoted span and instruct +UNKNOWN when the text to quote is absent. That stops every verdict and routes the whole run to +the human. Blindness here means blind to *the pipeline's own suspicion* — the fingerprint +numbers, the nomination's reasoning, the other judges — never blind to the material the +criteria are defined over. Handing a judge the fingerprint containment tells it the answer and turns three samples into one sample repeated, which measures nothing. @@ -80,8 +94,11 @@ not noise to be averaged away. **Lens diversity.** With `accuracy.judge_lens_diversity` on (the default), give each judge a distinct reading stance rather than the same prompt three times: one reads for whether the local text could have been written without the source in hand; one reads for what a reader loses if -the passage is replaced by a link; one reads for whether the attribution present already -discharges the obligation. Same rubric, same criteria, different entry point. Identical prompts +the passage is replaced by a link; one reads for whether the attribution's declared scope covers +the derivation it is being asked to discharge. Same rubric, same criteria, different entry point. +That third stance is deliberately not "is the attribution present and complete" — under rubric +version 3 that is the rejected reading, and pointing a judge at it biases the lens toward +clearing every well-headed file. Identical prompts measure self-consistency, which is not the quantity the panel exists to estimate. **Prompt shape.** @@ -102,6 +119,10 @@ measure self-consistency, which is not the quantity the panel exists to estimate > > LOCAL PASSAGE: [text] > SOURCE TEXT: [fetched bytes, with its URL and the rung it came from] +> LOCAL FILE: [the whole containing file, with the passage's line range marked] +> +> Grade C1, C2 and C4 on LOCAL PASSAGE. Grade C3 against LOCAL FILE, because it asks +> whether the attribution's scope matches the derivation's. **What the panel never decides.** The tier. Tier is mapped from evidence by fixed rule, never from a judge's confidence: a unanimous STANDS on a paraphrase is still `llm-suspected`, because @@ -116,12 +137,23 @@ Runs when `accuracy.review_agents` > 0, over STANDS verdicts only, before fix el > [framing block above] > > A finding has been judged STANDS. Your job is to try to break it. You have the local passage, -> the source text, and the criterion grades with their quoted evidence. You do not have the -> judges' reasoning beyond those quotes. +> the source text, the containing file, and the criterion grades with their quoted evidence. You +> do not have the judges' reasoning beyond those quotes. +> +> LOCAL PASSAGE: [text] +> SOURCE TEXT: [fetched bytes, with its URL and the rung it came from] +> LOCAL FILE: [the whole containing file, with the passage's line range marked] > > State whether each quoted span actually supports the grade it was given, and whether any > carve-out was missed. If the finding survives, say so plainly and briefly. +**The reviewer gets the containing file for the same reason the judge does.** Its job includes +checking the C3 grade and whether a carve-out was missed, and C3 is graded across the file while +carve-outs 1, 4 and 5 are file-level judgments. A reviewer holding only the passage cannot tell a +file-wide derivation from an isolated lift, so it would either decline the check or wave through +an unsupported C3 PASS — and this stage is the last one before fix eligibility, so waving one +through is what puts an unsupported finding in reach of an automatic edit. + **A review veto never reassigns a tier.** The tier mapping is fixed at contract time. A veto forces the finding's disposition to `leave-with-reason` and routes it to the human, so the finding stays visible on every surface and stops being fix-eligible. Record the outcome in the diff --git a/plugins/provenance/skills/audit/reference/rubric.md b/plugins/provenance/skills/audit/reference/rubric.md index 8c76e76a4..296375ca6 100644 --- a/plugins/provenance/skills/audit/reference/rubric.md +++ b/plugins/provenance/skills/audit/reference/rubric.md @@ -1,16 +1,34 @@ # The provenance rubric -Rubric version **2**. This catalog is versioned with the plugin: a change to a carve-out or a +Rubric version **3**. This catalog is versioned with the plugin: a change to a carve-out or a criterion lands in `CHANGELOG.md` and **invalidates any golden-set measurement pinned to the prior version**. A precision figure measured against rubric 1 says nothing about rubric 3. -**Version 2 is the one exception to that rule, and it is worth stating why rather than asserting -it.** Version 2 changed no criterion's substance and no carve-out. It corrected an inverted -polarity in C3's and C4's question wording and worked-example labels, which contradicted the -verdict rule stated three times elsewhere in this file. The version-1 measurement was computed -under the verdict rule, not under the inverted labels, and an independent blind adjudication -returned the same eight positives reading it the same way. So that measurement carries forward. -Any later change to what a criterion tests does invalidate it, as written above. +**Version 3 states the scope at which C3 is graded, and writes down C4's. It takes the +invalidation.** Version 2 left C3's scope implicit; two readers graded a real corpus case, agreed +on the verdict, and disagreed on which scope produced it, which is what surfaced the gap. C4's +scope was already passage-level in its worked examples and its replacement test, so recording it +changes nothing. C3's is a substantive addition: file-scope attribution now discharges C3 on a +file-wide derivation, which version 2's "a bare link at the bottom of a long file does not +attribute a specific paragraph in the middle of it" cuts against. + +That changes what C3 tests, so the measurement version 2 stands on does **not** carry forward. +**The golden set must be re-scored against version 3 before any precision figure is cited against +it**, and no class becomes fix-eligible on a measurement pinned to a superseded rubric. Stated so +the figures are not left under a cloud they do not deserve: **no current golden case appears to +turn on the scope question** — seven have no attribution anywhere, one is declined at a carve-out +before grading, one fails C1, and the single case with attribution is a lift inside an otherwise +original file, which resolves the same way at either scope. The re-score is expected to reproduce +8 tp / 0 fp / 0 fn / 2 tn. It is still required, because the rule keys on a criterion changing +rather than on a recorded case flipping. + +**Version 2 was the one exception to the invalidation rule**, kept here because the reasoning is +the standard the next exception has to meet. It changed no criterion's substance and no carve-out; +it corrected an inverted polarity in C3's and C4's question wording and worked-example labels, +which contradicted the verdict rule stated three times elsewhere in this file. The version-1 +measurement was computed under the verdict rule, not under the inverted labels, and an independent +blind adjudication returned the same eight positives reading it the same way. Version 3 cannot +make that argument, which is why it does not try. Read this at the judgment step. Judges apply it blind, three samples by default; unanimity renders the verdict and any split routes to the human. @@ -135,6 +153,29 @@ rubric whose criteria disagree about which way PASS points cannot render a verdi the inverse reading nothing could ever stand. That inversion shipped in rubric version 1 and was caught by a blind adjudication pass, not by review. +**Scope, stated once because C3 and C4 are graded at different ones.** C1 and C2 are graded on +the passage; nothing else would mean anything. The other two are not symmetric: + +- **C3 is graded outward from the passage, across the whole file.** This file already grades it + that way in its own worked examples: the PASS example turns on a URL "two sections below" the + restated text, and the FAIL example on a source line adjacent to a blockquote. Neither is + inside the span. What C3 asks is whether the attribution's declared **scope matches the + derivation's**. File-scope attribution discharges C3 when the derivation is file-wide; it does + not when one lift sits inside otherwise-original material, because there the header understates + and the reader misallocates which sentences came from upstream. "The attribution exists and is + complete" is not the test — that reading lets a single lift into an original file escape on a + header line about something else. +- **C4 is graded on the passage**, which is what its worked examples below already do, and what + its closing replacement test asks. A file can be substantially transformed while the span in + question adds nothing over its source, and it is the span that was copied. + +The asymmetry is the point, and it cuts both ways. Grade both at the file, and a majority-adapted +file **that carries adequate file-level attribution** clears twice — the qualifier matters, since +a file with no attribution anywhere still fails C3 at either scope. Grade both at the span, and a +well-attributed derived file stands every time. Version 2 left this implicit; the addition is +substantive for C3 and a written-down clarification for C4, which is why only C3's half carries +the invalidation recorded above. + ### C1-span-correspondence **Does a specific span of the local text correspond to a specific span of the named source?** diff --git a/plugins/provenance/skills/audit/scripts/check-stamps.sh b/plugins/provenance/skills/audit/scripts/check-stamps.sh index cc96cf1a0..629a56d10 100755 --- a/plugins/provenance/skills/audit/scripts/check-stamps.sh +++ b/plugins/provenance/skills/audit/scripts/check-stamps.sh @@ -316,12 +316,23 @@ function keyword_window(line, low, pos, off, kw, wlen) { pos = off + RSTART + RLENGTH - 1 kw = substr(low, off + RSTART, RLENGTH) wlen = (kw ~ /read/) ? 30 : 60 - win = substr(low, pos, wlen) - if (win ~ /[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/) return win - if (win ~ /[0-9]+\/[0-9]+\/[0-9]+/) return win - if (win ~ /(january|february|march|april|may|june|july|august|september|october|november|december)/) return win - if (win ~ /(jan|feb|mar|apr|jun|jul|aug|sep|oct|nov|dec)[^a-z]/) return win - if (win ~ /(19|20)[0-9][0-9]/) return win + # The window is a distance from the keyword, not a cut through the text: a + # date that STARTS inside it is read whole. So slice wlen plus 9 more + # characters — one short of the longest form matched below — and require + # each match to begin at or before wlen. Slicing at exactly wlen truncated + # "as-of 2026-08-17" to "2026-08-", the ISO test failed on the fragment, + # and the bare-year fallback claimed the "2026" left behind: a conforming + # stamp was declined and so never expiry-checked + # (docs/upstream/aihero-course.md:127, corpus sweep of 2026-08-28). + # Each test returns the window cut at the end of its own match, so the + # caller classifies on the match this function accepted and never on one + # sitting in the 9 characters of slack. + win = substr(low, pos, wlen + 9) + if (match(win, /[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/) && RSTART <= wlen) return substr(win, 1, RSTART + RLENGTH - 1) + if (match(win, /[0-9]+\/[0-9]+\/[0-9]+/) && RSTART <= wlen) return substr(win, 1, RSTART + RLENGTH - 1) + if (match(win, /(january|february|march|april|may|june|july|august|september|october|november|december)/) && RSTART <= wlen) return substr(win, 1, RSTART + RLENGTH - 1) + if (match(win, /(jan|feb|mar|apr|jun|jul|aug|sep|oct|nov|dec)[^a-z]/) && RSTART <= wlen) return substr(win, 1, RSTART + RLENGTH - 1) + if (match(win, /(19|20)[0-9][0-9]/) && RSTART <= wlen) return substr(win, 1, RSTART + RLENGTH - 1) off = pos if (off >= length(low)) return "" } diff --git a/plugins/provenance/skills/audit/scripts/check-stamps.test.sh b/plugins/provenance/skills/audit/scripts/check-stamps.test.sh index c5e460a3a..9c76df2d4 100755 --- a/plugins/provenance/skills/audit/scripts/check-stamps.test.sh +++ b/plugins/provenance/skills/audit/scripts/check-stamps.test.sh @@ -86,6 +86,13 @@ mkdir -p "$DIR" echo 'Issue 1638 tracks the follow-up work.' # 5 } >"$DIR/neither.md" +{ + echo '# Window edge' # 1 + echo '' # 2 + echo 'Measured bands overclaim (lane-6 correction pending). As-of 2026-02-28 here.' # 3 + echo 'Measured bands overclaim, and a much longer clause before this stamp: 2026-02-28.' # 4 +} >"$DIR/window-edge.md" + { echo '# Trigger present' # 1 echo '' # 2 @@ -180,6 +187,28 @@ assert_eq "candidates equal parsed plus declined" \ assert_eq "parsed counts every ISO stamp" \ "$(echo "$OUT" | jq -r '.counts.parsed')" "4" +# --- Window edge ----------------------------------------------------------------- +# +# The keyword window is a distance from the keyword, not a cut through the text: +# a date that STARTS inside it has to be read whole. Slicing at exactly the +# window truncated "As-of 2026-02-28" to "2026-02-", the ISO test failed on the +# fragment, and the bare-year fallback then claimed the "2026" left behind. The +# stamp was declined instead of parsed, so its expiry was never checked — the +# failure this script exists to catch, reported as a decline reason. Found at +# docs/upstream/aihero-course.md:127 in the 2026-08-28 corpus sweep. + +OUT="$(run "$DIR/window-edge.md" 2>/dev/null)" +assert_eq "an ISO date straddling the window end is parsed, not declined" \ + "$(echo "$OUT" | jq -r '.counts.parsed')" "1" +assert_eq "nothing at the window edge is declined" \ + "$(echo "$OUT" | jq -r '.counts.declined')" "0" +assert_eq "a straddling stamp reports the whole date" \ + "$(echo "$OUT" | jq -r '.findings[] | select(.line == 3) | .stamp_date')" "2026-02-28" +assert_eq "a straddling stamp is expiry-checked like any other" \ + "$(echo "$OUT" | jq -r '.findings[] | select(.line == 3) | .days_over')" "1" +assert_eq "a date starting past the window is still not a candidate" \ + "$(echo "$OUT" | jq -r '.counts.candidates')" "1" + # --- Trigger-less check ---------------------------------------------------------- OUT="$(run "$DIR/no-trigger.md" 2>/dev/null)" diff --git a/plugins/provenance/skills/audit/scripts/extract-breadcrumbs.sh b/plugins/provenance/skills/audit/scripts/extract-breadcrumbs.sh index 4bb96ec91..235235433 100755 --- a/plugins/provenance/skills/audit/scripts/extract-breadcrumbs.sh +++ b/plugins/provenance/skills/audit/scripts/extract-breadcrumbs.sh @@ -197,12 +197,20 @@ function is_stamp(line, low, pos, rest, off, kw, wlen) { pos = off + RSTART + RLENGTH - 1 kw = substr(low, off + RSTART, RLENGTH) wlen = (kw ~ /read/) ? 30 : 60 - rest = substr(low, pos, wlen) - if (rest ~ /[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/) return 1 - if (rest ~ /[0-9]+\/[0-9]+\/[0-9]+/) return 1 - if (rest ~ /(19|20)[0-9][0-9]/) return 1 - if (rest ~ /(january|february|march|april|may|june|july|august|september|october|november|december)/) return 1 - if (rest ~ /(jan|feb|mar|apr|jun|jul|aug|sep|oct|nov|dec)[^a-z]/) return 1 + # Same window rule as check-stamps.sh keyword_window(): the window is a + # distance from the keyword, not a cut through the text. Slice wlen plus 9 + # more characters, one short of the longest form matched below, and require + # each match to BEGIN at or before wlen. Slicing at exactly wlen dropped + # docs/CLOUD-SESSIONS.md:320 from this inventory while check-stamps.sh + # counted it a candidate: its date starts at offset 60 of 60, so the cut + # left a bare "2" and no form matched. These two scripts promise the same + # candidate definition, so the boundary has to be the same in both. + rest = substr(low, pos, wlen + 9) + if (match(rest, /[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/) && RSTART <= wlen) return 1 + if (match(rest, /[0-9]+\/[0-9]+\/[0-9]+/) && RSTART <= wlen) return 1 + if (match(rest, /(19|20)[0-9][0-9]/) && RSTART <= wlen) return 1 + if (match(rest, /(january|february|march|april|may|june|july|august|september|october|november|december)/) && RSTART <= wlen) return 1 + if (match(rest, /(jan|feb|mar|apr|jun|jul|aug|sep|oct|nov|dec)[^a-z]/) && RSTART <= wlen) return 1 off = pos if (off >= length(low)) return 0 } diff --git a/plugins/provenance/skills/audit/scripts/extract-breadcrumbs.test.sh b/plugins/provenance/skills/audit/scripts/extract-breadcrumbs.test.sh index 70093eab8..f9f6a4590 100755 --- a/plugins/provenance/skills/audit/scripts/extract-breadcrumbs.test.sh +++ b/plugins/provenance/skills/audit/scripts/extract-breadcrumbs.test.sh @@ -186,6 +186,36 @@ assert_eq "a non-ISO stamp form is still inventoried" \ assert_eq "a bare date with no stamp keyword is not a stamp line" \ "$(echo "$SIB_OUT" | jq -r "$SIB | [.stamp_lines[] | select(.line == 4)] | length")" "0" +# A date that STARTS inside the keyword window but ENDS past it must still make +# the line a stamp. Slicing the window at exactly its length cut the date and +# left nothing date-shaped, so the line vanished from the inventory while +# check-stamps.sh counted it as a candidate. The two scripts promise the same +# candidate definition, so they have to agree on the boundary. +STRADDLE_DIR="$TEST_TMPDIR/straddle" +mkdir -p "$STRADDLE_DIR" +{ + printf '# Straddle\n\n' + # Taken from docs/CLOUD-SESSIONS.md:320, which check-stamps.sh counts as a + # candidate and this extractor did not. One keyword, and the date sits far + # enough from it that the 60-character slice cut the year in half. Note the + # line must carry no SECOND keyword: "as-of" beside the date would restart + # the scan there and mask the truncation entirely. + # shellcheck disable=SC2016 # fixture text lifted verbatim from the corpus; the backticks are + # literal markdown and part of the character count that puts the date at offset 60. + printf ' synced set (verified against the `chore: sync standards components` history on 2026-07-30), so\n\n' + # Negative control: the same shape with the date pushed to offset 64, + # genuinely past the window. The added slack must not admit it, or the fix + # trades a false negative for a false positive. + # shellcheck disable=SC2016 # same literal fixture text, date shifted past the window. + printf ' synced set (verified against the `chore: sync standards componentsxxxx` history on 2026-07-30), so\n' +} >"$STRADDLE_DIR/straddle.md" + +STRADDLE_OUT="$(run --files "$STRADDLE_DIR/straddle.md" 2>/dev/null)" +assert_eq "a stamp whose date straddles the window end is inventoried" \ + "$(echo "$STRADDLE_OUT" | jq -r '.directories[0].files[0] | [.stamp_lines[] | select(.line == 3)] | length')" "1" +assert_eq "a date starting past the window is still not a stamp" \ + "$(echo "$STRADDLE_OUT" | jq -r '.directories[0].files[0] | [.stamp_lines[] | select(.line == 5)] | length')" "0" + # --- Fences ---------------------------------------------------------------------- COPIED='.directories[0].files[] | select(.file | endswith("copied.md"))'