From 385daaf8e9f9435d846541284b272f10aef1f3d3 Mon Sep 17 00:00:00 2001 From: ArchonVII Date: Wed, 1 Jul 2026 19:29:37 -0500 Subject: [PATCH 1/3] feat(pr-contract): substance-only verification items (#99) Any non-empty bullet or checkbox in ## Verification now counts as a verification item; unchecked boxes, missing evidence blocks, and heading presence/order demote to advisories; placeholder and generic-claim rejection plus section-substance checks stay hard. Fenced content is masked from item collection so evidence output is never misread as a claim. Advisories surface in the formatted result on success. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017cTBuec3roYwjTcVNToC4u --- scripts/pr-contract.mjs | 108 ++++++++++++++++++------- scripts/pr-contract.test.mjs | 148 ++++++++++++++++++++++++++++++++++- 2 files changed, 224 insertions(+), 32 deletions(-) diff --git a/scripts/pr-contract.mjs b/scripts/pr-contract.mjs index 069eea7..899eaa8 100644 --- a/scripts/pr-contract.mjs +++ b/scripts/pr-contract.mjs @@ -17,8 +17,14 @@ const TITLE_RE = /^(feat|fix|refactor|test|docs|style|chore|perf|ci|build|revert const ISSUE_RE = /\b(Closes|Fixes|Refs)\s+#\d+\b/i; // "placeholder" is valid completed prose; reject explicit unfilled markers. const PLACEHOLDER_RE = /\b(TODO|TBD|FIXME|FILL ME|FILL IN|REPLACE THIS|NOT YET|N\/A|NONE YET)\b|#\s*(?:___|<[^>]+>)|/i; -const CHECKED_RE = /^\s*-\s+\[[xX]\]\s+(.+?)\s*$/; -const UNCHECKED_RE = /^\s*-\s+\[\s\]\s+(.+?)\s*$/; +const CHECKED_RE = /^\s*[-*]\s+\[[xX]\]\s+(.+?)\s*$/; +const UNCHECKED_RE = /^\s*[-*]\s+\[\s\]\s+(.+?)\s*$/; +// Substance-only contract (owner decision 2026-07-01, #99): any non-empty +// bullet in `## Verification` counts as a verification item — the contract +// requires that something substantive is recorded, not a specific checkbox +// format. The negative lookahead keeps checkbox lines out of the plain-item +// match so each line is classified exactly once. +const ITEM_RE = /^\s*[-*]\s+(?!\[[ xX]\]\s)(.+?)\s*$/; const GENERIC_VERIFICATION_RE = /\b(automated ci checks? green|ci[- ]?green|ci checks? pass(?:ed|es)?|checks? green|all checks? pass(?:ed|es)?|tests? pass(?:ed|es)?)\b/i; /** @@ -69,13 +75,16 @@ export function validatePrContract(input, options = {}) { validateBody(data.body, rules, errors, warnings); } + const items = collectVerificationItems(data.body); return { ok: errors.length === 0, errors, warnings, facts: { docsOnly, - checkedVerificationCount: countCheckedVerificationItems(data.body), + checkedVerificationCount: items.checked.length, + verificationItemCount: [...items.checked, ...items.unchecked, ...items.plain] + .filter((item) => item.claim.length > 0).length, }, }; } @@ -83,7 +92,14 @@ export function validatePrContract(input, options = {}) { export function formatPrContractResult(result) { if (result.ok) { const suffix = result.facts.docsOnly ? ' (docs-only body ceremony skipped)' : ''; - return `PR contract passed${suffix}.`; + const lines = [`PR contract passed${suffix}.`]; + if (result.warnings.length > 0) { + lines.push('', 'Advisories (non-blocking):'); + for (const item of result.warnings) { + lines.push(`- [${item.code}] ${item.message}`); + } + } + return lines.join('\n'); } const lines = ['PR contract failed.', '', 'Required fixes:']; @@ -91,7 +107,7 @@ export function formatPrContractResult(result) { lines.push(`- [${item.code}] ${item.message}`); } if (result.warnings.length > 0) { - lines.push('', 'Warnings:'); + lines.push('', 'Advisories (non-blocking):'); for (const item of result.warnings) { lines.push(`- [${item.code}] ${item.message}`); } @@ -188,7 +204,10 @@ function validateBody(body, rules, errors, warnings) { )); } - validateHeadingOrder(headings, rules.requiredHeadings, errors); + // Heading presence/order is advisory since #99: sections are located by + // name, so the substance checks below still hard-fail when a section's + // content is genuinely missing — only the exact structure is soft. + validateHeadingOrder(headings, rules.requiredHeadings, warnings); const summary = sectionContent(body, headings, 'Summary'); if (rules.requireSummary && !hasSubstantiveContent(summary)) { @@ -216,44 +235,41 @@ function validateBody(body, rules, errors, warnings) { errors.push(error('empty_docs_changelog', '`## Docs / Changelog` must describe docs or changelog handling.', 'body')); } - const verification = sectionContent(body, headings, 'Verification', { stopBefore: 'Verification Notes' }); - const checkedItems = verification.split(/\r?\n/).map((line, index) => { - const match = line.match(CHECKED_RE); - return match ? { claim: match[1].trim(), index } : null; - }).filter(Boolean); - const uncheckedItems = verification.split(/\r?\n/).map((line) => { - const match = line.match(UNCHECKED_RE); - return match ? match[1].trim() : null; - }).filter(Boolean); - - if (rules.requireCheckedVerification && checkedItems.length === 0) { + const { verification, checked, unchecked, plain } = collectVerificationItems(body); + const allItems = [...checked, ...unchecked, ...plain]; + const substantiveItems = allItems.filter((item) => item.claim.length > 0); + + if (rules.requireCheckedVerification && substantiveItems.length === 0) { errors.push(error( - 'missing_checked_verification', - '`## Verification` must include at least one checked verification item.', + 'missing_verification_item', + '`## Verification` must include at least one verification item — a bullet or checkbox recording what was actually run or checked.', 'body', )); } - for (const claim of uncheckedItems) { - errors.push(error( - 'unchecked_required_box', - `Unchecked verification item must be completed or removed before ready-for-review: "${claim}".`, + for (const item of unchecked) { + warnings.push(error( + 'unchecked_verification_item', + `Verification item is unchecked; it still counts as an item, but reads as not-done: "${item.claim}".`, 'body', )); } - for (const item of checkedItems) { + for (const item of allItems) { if (GENERIC_VERIFICATION_RE.test(item.claim)) { errors.push(error( 'generic_verification', - `Checked verification item is too generic: "${item.claim}". Record the actual command, check, or manual action.`, + `Verification item is too generic: "${item.claim}". Record the actual command, check, or manual action.`, 'body', )); } + } + + for (const item of checked) { if (rules.requireEvidenceBlocks && !hasEvidenceBlockAfter(verification, item.index)) { - errors.push(error( + warnings.push(error( 'missing_evidence_block', - `Checked verification item "${item.claim}" must be followed by a fenced \`\`\`evidence block.`, + `Checked verification item "${item.claim}" is not followed by a fenced \`\`\`evidence block (recommended shape; advisory since #99).`, 'body', )); } @@ -375,10 +391,44 @@ function hasEvidenceBlockAfter(section, checkedLineIndex) { return false; } -function countCheckedVerificationItems(body) { +// Classify every line of the `## Verification` section (fenced blocks masked +// so evidence field values are never misread as items) into checked boxes, +// unchecked boxes, and plain bullets. Line indexes are preserved through the +// masking so evidence-adjacency checks still run against the raw section. +function collectVerificationItems(body) { const headings = parseHeadings(body || ''); const verification = sectionContent(body || '', headings, 'Verification', { stopBefore: 'Verification Notes' }); - return verification.split(/\r?\n/).filter((line) => CHECKED_RE.test(line)).length; + const checked = []; + const unchecked = []; + const plain = []; + maskFencedLines(verification).forEach((line, index) => { + let match = line.match(CHECKED_RE); + if (match) { + checked.push({ claim: match[1].trim(), index }); + return; + } + match = line.match(UNCHECKED_RE); + if (match) { + unchecked.push({ claim: match[1].trim(), index }); + return; + } + match = line.match(ITEM_RE); + if (match) plain.push({ claim: match[1].trim(), index }); + }); + return { verification, checked, unchecked, plain }; +} + +// Blank out fence delimiters and fenced content while preserving line count, +// so indexes into the raw section stay valid. +function maskFencedLines(text) { + let inFence = false; + return String(text || '').split(/\r?\n/).map((line) => { + if (/^\s*```/.test(line)) { + inFence = !inFence; + return ''; + } + return inFence ? '' : line; + }); } function isDocsOnly(files, rules) { diff --git a/scripts/pr-contract.test.mjs b/scripts/pr-contract.test.mjs index 1a79689..501af44 100644 --- a/scripts/pr-contract.test.mjs +++ b/scripts/pr-contract.test.mjs @@ -1,7 +1,7 @@ import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { validatePrContract, validatePrTemplate, formatPrTemplateResult } from './pr-contract.mjs'; +import { validatePrContract, validatePrTemplate, formatPrContractResult, formatPrTemplateResult } from './pr-contract.mjs'; const scriptPath = fileURLToPath(new URL('./pr-contract.mjs', import.meta.url)); @@ -58,7 +58,7 @@ describe('validatePrContract', () => { ); }); - it('requires exact heading order for non-doc PRs', () => { + it('still fails a renamed section heading — via the substance check, with a heading advisory (#99)', () => { const body = validBody.replace( '### Verification Notes', '### Notes From Verification', @@ -68,6 +68,45 @@ describe('validatePrContract', () => { expect(result.ok).toBe(false); expect(result.errors).toContainEqual( + expect.objectContaining({ code: 'empty_verification_notes', path: 'body' }), + ); + expect(result.warnings).toContainEqual( + expect.objectContaining({ code: 'missing_heading', path: 'body' }), + ); + }); + + it('passes out-of-order sections with a heading advisory when all substance is present (#99)', () => { + const body = [ + '## Verification', + '', + '- [x] npm test', + '', + '```evidence', + 'command: npm test', + 'location: local', + 'result: passed', + 'timestamp: 2026-05-31T20:00:00Z', + '```', + '', + '### Verification Notes', + '', + 'Validated the reusable workflow tests locally with `npm test`; no warnings were emitted.', + '', + '## Summary', + '', + '- Add strict PR contract validation before ready-for-review.', + '', + '## Docs / Changelog', + '', + '- README and reusable workflow examples updated for the new contract.', + '', + 'Closes #36', + ].join('\n'); + + const result = validatePrContract(input({ body })); + + expect(result.ok).toBe(true); + expect(result.warnings).toContainEqual( expect.objectContaining({ code: 'missing_heading', path: 'body' }), ); }); @@ -101,10 +140,13 @@ describe('validatePrContract', () => { expect.arrayContaining([ 'placeholder_text', 'generic_verification', - 'unchecked_required_box', 'missing_issue_link', ]), ); + // Unchecked boxes are advisory since #99 — they count as items but warn. + expect(result.warnings).toContainEqual( + expect.objectContaining({ code: 'unchecked_verification_item' }), + ); }); it('allows docs-only PRs to skip body ceremony while keeping title and branch checks', () => { @@ -322,6 +364,106 @@ describe('context-aware parser (acceptance table)', () => { }); }); +// Substance-only contract (owner decision 2026-07-01, #99): require that a +// verification item exists and is substantive; stop failing on exact format. +describe('substance-only verification items (#99)', () => { + const bodyWithVerification = (verificationLines) => [ + '## Summary', + '', + '- Substance-only verification contract test case.', + '', + '## Verification', + '', + ...verificationLines, + '', + '### Verification Notes', + '', + 'Ran the listed commands in the lane worktree; output recorded above.', + '', + '## Docs / Changelog', + '', + '- README updated alongside this change.', + '', + 'Closes #99', + ].join('\n'); + + it('accepts a plain-bullet verification item with no checkbox and no evidence block', () => { + const result = validatePrContract(input({ + body: bodyWithVerification(['- Ran `npm test` in the worktree: 159/159 green.']), + })); + expect(result.ok).toBe(true); + expect(result.errors).toEqual([]); + expect(result.facts.verificationItemCount).toBe(1); + }); + + it('accepts `*` bullets as verification items', () => { + const result = validatePrContract(input({ + body: bodyWithVerification(['* Ran `npm test` in the worktree: 159/159 green.']), + })); + expect(result.ok).toBe(true); + }); + + it('warns (not fails) on a checked item without an evidence block', () => { + const result = validatePrContract(input({ + body: bodyWithVerification(['- [x] Ran `npm test` in the worktree: 159/159 green.']), + })); + expect(result.ok).toBe(true); + expect(result.warnings).toContainEqual( + expect.objectContaining({ code: 'missing_evidence_block' }), + ); + }); + + it('warns (not fails) on a substantive unchecked item, which still counts', () => { + const result = validatePrContract(input({ + body: bodyWithVerification(['- [ ] Re-ran the flaky suite twice; both runs green.']), + })); + expect(result.ok).toBe(true); + expect(result.warnings).toContainEqual( + expect.objectContaining({ code: 'unchecked_verification_item' }), + ); + expect(result.facts.verificationItemCount).toBe(1); + }); + + it('fails when the Verification section has no items at all', () => { + const result = validatePrContract(input({ + body: bodyWithVerification(['Nothing was run.']), + })); + expect(result.ok).toBe(false); + expect(result.errors.map((e) => e.code)).toContain('missing_verification_item'); + }); + + it('fails a generic plain bullet ("tests pass") just like a generic checked claim', () => { + const result = validatePrContract(input({ + body: bodyWithVerification(['- tests pass']), + })); + expect(result.ok).toBe(false); + expect(result.errors.map((e) => e.code)).toContain('generic_verification'); + }); + + it('does not count bullet-like lines inside fenced blocks as items', () => { + const result = validatePrContract(input({ + body: bodyWithVerification([ + '```', + '- [x] this is quoted output, not a claim', + '- neither is this', + '```', + ]), + })); + expect(result.ok).toBe(false); + expect(result.errors.map((e) => e.code)).toContain('missing_verification_item'); + }); + + it('reports advisories in the formatted result on success', () => { + const result = validatePrContract(input({ + body: bodyWithVerification(['- [x] Ran `npm test` in the worktree: 159/159 green.']), + })); + const report = formatPrContractResult(result); + expect(result.ok).toBe(true); + expect(report).toContain('Advisories (non-blocking):'); + expect(report).toContain('missing_evidence_block'); + }); +}); + // The keystone: the same validator runs on a drafted body BEFORE a PR exists. describe('pr-contract --body-file (pre-publish, no PR)', () => { const runBodyFile = (body, extraArgs = []) => { From 83d737dad8c06914dbeab9e78eda3caaf5719414 Mon Sep 17 00:00:00 2001 From: ArchonVII Date: Wed, 1 Jul 2026 19:29:38 -0500 Subject: [PATCH 2/3] docs(templates): reconcile PR templates with the substance-only contract (#99) Fix the mislabeled PULL_REQUEST_TEMPLATE (claimed to be repo-template's and denied this repo's CI surface), make contracts/pr-template.md a scaffold that passes the gate it models, and update pr-policy.yml header comments and the require-checked-box description (input name kept for caller compatibility). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017cTBuec3roYwjTcVNToC4u --- .github/PULL_REQUEST_TEMPLATE.md | 28 +++++++++++++++--------- .github/workflows/pr-policy.yml | 10 +++++---- contracts/pr-template.md | 37 +++++++++++++++++++++++++++++--- 3 files changed, 58 insertions(+), 17 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e47d894..65d7ff8 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,11 +1,17 @@ ## Summary @@ -14,7 +20,7 @@ TODO: What changed and why? ## Verification -- [ ] TODO: Replace with an exact command, CI check, or manual smoke test. +- TODO: Replace with the exact command, CI check, or manual smoke test you ran and its result. ```evidence command: TODO @@ -25,9 +31,9 @@ TODO: What changed and why? ### Verification Notes -Each checked box below must be backed by exactly one fenced `evidence` block. The PR-policy parser (warning-only in Phase 1, will hard-fail in Phase 2+) reads them. +Record concrete detail: command output, CI check names, or manual smoke-test notes. Generic "tests passed / CI green" statements are rejected. -Required fields: `command`, `location` (one of `local` / `ci` / `manual`), `result`, `timestamp`. Optional: `check` (used when `location: ci` and the check-run name differs from the command). +Evidence-block fields when you use one: `command`, `location` (one of `local` / `ci` / `manual`), `result`, `timestamp`. Optional: `check` (used when `location: ci` and the check-run name differs from the command). TODO: Summarize the exact verification evidence and any manual review. @@ -37,6 +43,8 @@ TODO: Record the changelog fragment, direct CHANGELOG edit, docs update, or no-c Plan/status artifacts: TODO: closed, narrowed to remaining scoped work, marked deprecated/superseded with the current source of truth, or not applicable because none were created or used by this lane. +Owner decisions this lane: appended / none + ## Linked Issue TODO: Closes #\_\_\_ diff --git a/.github/workflows/pr-policy.yml b/.github/workflows/pr-policy.yml index 66d6e9d..42c1218 100644 --- a/.github/workflows/pr-policy.yml +++ b/.github/workflows/pr-policy.yml @@ -2,9 +2,11 @@ name: PR Policy (reusable) # Reusable workflow that enforces a minimum PR body contract: # 1. PR body links an issue (Closes / Fixes / Refs #N) -# 2. PR body contains a Verification heading -# 3. PR body contains a Verification Notes heading -# 4. At least one verification checkbox is checked (`- [x]`) +# 2. PR body contains substantive Verification content +# 3. PR body contains substantive Verification Notes content +# 4. At least one substantive verification item — a plain bullet or a +# checkbox recording what was actually run (substance-only since #99; +# the exact `- [x]` format is no longer required) # # Doc-only PRs (every changed file matches the doc filter) skip the ceremony. # Draft PRs are advisory only. @@ -21,7 +23,7 @@ on: type: boolean default: true require-checked-box: - description: "Require at least one `- [x]` checked box in the body." + description: "Require at least one substantive verification item (bullet or checkbox) in the body. Name kept for caller compatibility; substance-only since #99." type: boolean default: true strict-body-contract: diff --git a/contracts/pr-template.md b/contracts/pr-template.md index c878b33..caf9705 100644 --- a/contracts/pr-template.md +++ b/contracts/pr-template.md @@ -1,10 +1,31 @@ + + ## Summary TODO: Replace with a concise description of the change. ## Verification -- [ ] TODO: Run required verification and replace this line. +- TODO: The exact command, CI check, or manual smoke test you ran, with its result. + + ```evidence + command: TODO + location: local + result: TODO + timestamp: TODO + ``` ### Verification Notes @@ -12,8 +33,18 @@ TODO: Record concrete command output, CI check names, or manual smoke-test notes ## Docs / Changelog -TODO: Record docs/changelog handling. +TODO: Record docs/changelog handling (fragment, direct edit, docs update, or no-changelog label). + +Plan/status artifacts: TODO: closed, narrowed, superseded-with-pointer, or not applicable. + +Owner decisions this lane: appended / none ## Linked Issue -TODO: Closes #___ +TODO: Closes #\_\_\_ + +## Risks + +- Risk level: +- Rollback: +- Follow-ups: From 70f984293b724a1641210cd7d5a7f2e94aefd66b Mon Sep 17 00:00:00 2001 From: ArchonVII Date: Wed, 1 Jul 2026 19:29:39 -0500 Subject: [PATCH 3/3] docs: retire persona language, fix inventory count, log #99 (#99) AGENTS.md still instructed the retired Issue-Admiral/Project-Captain/ Project-Lieutenant/Release-Admiral personas, contradicting the README; README said 19 reusables where 20 exist and described the pre-#99 checked-box rule. Adds the changelog fragment and update-log entry. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017cTBuec3roYwjTcVNToC4u --- .../unreleased/99-substance-only-contract.md | 1 + AGENTS.md | 2 +- README.md | 20 +++++++++++++------ docs/repo-update-log.md | 9 +++++++++ 4 files changed, 25 insertions(+), 7 deletions(-) create mode 100644 .changelog/unreleased/99-substance-only-contract.md diff --git a/.changelog/unreleased/99-substance-only-contract.md b/.changelog/unreleased/99-substance-only-contract.md new file mode 100644 index 0000000..0e964e9 --- /dev/null +++ b/.changelog/unreleased/99-substance-only-contract.md @@ -0,0 +1 @@ +- **Substance-only PR verification contract.** `scripts/pr-contract.mjs` now counts any non-empty bullet or checkbox in `## Verification` as a verification item (`missing_verification_item` replaces `missing_checked_verification`); unchecked boxes and missing evidence blocks are advisories instead of failures; heading presence/order is advisory while section substance stays enforced; placeholder and generic "tests pass"/"CI green" claims still hard-fail; fenced content inside the Verification section is no longer misread as items; advisories now surface in the job summary on success. Templates (`.github/PULL_REQUEST_TEMPLATE.md`, `contracts/pr-template.md`), README contract description, and pr-policy header docs reconciled; retired persona language removed from AGENTS.md. Caller inputs unchanged — consumers pick this up at the next `@v1` retag. (#99) diff --git a/AGENTS.md b/AGENTS.md index 5c8c084..5340fa3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,7 +119,7 @@ Optional capabilities (claims #14, close-scan #28) are reported as "not installe ## Owner Maintenance Lane -When the working tree contains only add-only safe maintenance files, agents must not invoke Issue-Admiral, Project-Captain, Project-Lieutenant, Release-Admiral, claim records, handoff blocks, or full CI. Either report `owner maintenance present, no action required` or, if explicitly asked to commit, commit directly on `main` with `docs(owner): ...` or `chore(owner): ...`. +When the working tree contains only add-only safe maintenance files, agents must not start a delivery lane (issue/branch/PR), write claim records or handoff blocks, or trigger full CI. Either report `owner maintenance present, no action required` or, if explicitly asked to commit, commit directly on `main` with `docs(owner): ...` or `chore(owner): ...`. (Persona-role language — Issue-Admiral / Project-Captain / Project-Lieutenant / Release-Admiral — is retired; see README "role separation".) Safe owner-maintenance paths are: diff --git a/README.md b/README.md index 09bdb30..11bebde 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ full immutability can pin to a commit SHA instead of `@v1`. ## Workflow Inventory -This repo currently contains 19 reusable `workflow_call` workflows and 2 +This repo currently contains 20 reusable `workflow_call` workflows and 2 provider self-test workflows. ### Required Gate And PR Policy @@ -161,13 +161,21 @@ docs/changelog, and issue-link content. [`scripts/pr-contract.mjs`](scripts/pr-contract.mjs) for the shared PR contract. The contract can require: -- `Summary`, `Verification`, `Verification Notes`, `Docs / Changelog`, and - `Linked Issue` sections. -- A `Closes #N`, `Fixes #N`, or `Refs #N` issue link. -- At least one checked verification item. -- Non-placeholder verification notes. +- Substantive `Summary`, `Verification`, `Verification Notes`, and + `Docs / Changelog` content (headings are located by name; presence/order of + the exact heading structure is advisory since #99). +- A `Closes #N`, `Fixes #N`, or `Refs #N` issue link anywhere in the body. +- At least one substantive verification item — a plain bullet or a checkbox + recording what was actually run or checked (substance-only since #99; + the exact `- [x]` format is no longer required). +- Non-placeholder, non-generic verification content ("tests pass" / + "CI green" claims still hard-fail). - Branch names matching the configured convention. +Evidence blocks remain the recommended verification shape: they are validated +when present, and a checked item without one now draws an advisory warning +instead of a failure. + Doc-only PRs can skip the strict body ceremony when every changed file matches the configured doc-only extensions or prefixes. diff --git a/docs/repo-update-log.md b/docs/repo-update-log.md index 696819d..8a3df7f 100644 --- a/docs/repo-update-log.md +++ b/docs/repo-update-log.md @@ -15,6 +15,15 @@ This log records agent-visible repository changes that should be easy to audit l - **Propagation:** none | pending | completed ``` +## 2026-07-01 - Substance-only PR verification contract + +- **Issue/PR:** #99 / (pending) +- **Branch:** agent/claude/99-substance-only-contract +- **Changed paths:** scripts/pr-contract.mjs, scripts/pr-contract.test.mjs, .github/workflows/pr-policy.yml (comments/description only), .github/PULL_REQUEST_TEMPLATE.md, contracts/pr-template.md, README.md, AGENTS.md, .changelog/unreleased/99-substance-only-contract.md +- **What changed:** Verification items are substance-only — any non-empty bullet or checkbox counts (`missing_verification_item` replaces `missing_checked_verification`); unchecked boxes, missing evidence blocks, and heading order demote to advisories; placeholder + generic-claim rejection and section-substance checks stay hard; fenced content masked from item collection; advisories print on success. Templates, README contract description, and pr-policy header reconciled; mislabeled github-workflows PR template fixed (claimed to be repo-template's, denied this repo's CI surface); retired persona language removed from AGENTS.md; workflow inventory count corrected 19→20. +- **Verification:** `npm test` — vitest 9 files / 168 tests / 168 pass on the lane worktree (baseline before change: 159; net +9 covering bullets-count, unchecked-warns, evidence-warns, order-advisory, fence-masking, generic-bullet-fails, advisories-on-success). +- **Propagation:** pending — `@v1` retag (owner-gated) reaches all consumers' CI; repo-template re-vendor of `scripts/pr-contract.mjs` + parity test is the next friction-arc lane; archon-setup snapshot refresh follows. + ## 2026-06-21 - Force LF for .mjs (Windows shebang loader fix) - **Issue/PR:** #89 / (pending)