diff --git a/.faff/anchors/run-20261104-043908-graft-FAFF-1428/FAFF-1428/ac-checklist.json b/.faff/anchors/run-20261104-043908-graft-FAFF-1428/FAFF-1428/ac-checklist.json new file mode 100644 index 000000000..be1d27433 --- /dev/null +++ b/.faff/anchors/run-20261104-043908-graft-FAFF-1428/FAFF-1428/ac-checklist.json @@ -0,0 +1,495 @@ +{ + "schema_version": 1, + "faff_version": "0.44.0", + "all_verified": true, + "criteria": [ + { + "ac": "A PR that deletes a non-exempt block of skill prose, commits a spec, and has no row or move covering the block fails `faff rule-inventory check` with exit 1, naming the file and base line.", + "verified": "exit 1, uncovered names plugin/skills/faff-graft/SKILL.md:5", + "passed": true, + "ac_id": "faff-1428#done:1", + "verifications": [ + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::a deleted paragraph with a committed spec and no inventory section fails, naming the file and the missing section" + } + ] + }, + { + "ac": "`faff rule-inventory check [--base REF] [--json] [--summary FILE]` exists, with exits 0 / 1 / 2 as section 3 defines.", + "verified": "exits 0/1/2 asserted; self-run on this branch exits 0 (skipped no-prose-deleted)", + "passed": true, + "ac_id": "faff-1428#done:2", + "verifications": [ + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::a deleted paragraph with a committed spec and no inventory section fails, naming the file and the missing section" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::a --base that is not a plain ref exits 2 and prints nothing on stdout" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::a failing git command exits 2, never 0" + }, + { + "kind": "command", + "locator": "node plugin/skills/faff/bin/faff rule-inventory check --base origin/main --json" + } + ] + }, + { + "ac": "`--json` output has every `CheckResult` field.", + "verified": "every CheckResult key asserted present", + "passed": true, + "ac_id": "faff-1428#done:3", + "verifications": [ + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::a deleted paragraph with a committed spec and no inventory section fails, naming the file and the missing section" + } + ] + }, + { + "ac": "The inventory format (section heading, `Prose rule` code-span anchor, `Covering test`, `Disposition`) is documented in `docs/reference/skill-authoring.md` under a `Rule inventory` heading, and every uncovered-block message names that anchor.", + "verified": "section at docs/reference/skill-authoring.md:37; uncovered line names #rule-inventory", + "passed": true, + "ac_id": "faff-1428#done:4", + "verifications": [ + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::a deleted paragraph with a committed spec and no inventory section fails, naming the file and the missing section" + }, + { + "kind": "command", + "locator": "grep -n '^## Rule inventory' docs/reference/skill-authoring.md" + } + ] + }, + { + "ac": "`plugin/skills/faffter-dark-nlspec/SKILL.md` gains one line telling the producer that a spec deleting skill prose carries a `## Rule inventory` in that format, and `prose-baseline.json` is raised by exactly that file's growth.", + "verified": "one Rule inventory line added; prose-baseline raised 18443 -> 18680 (+237, the file's exact growth); validate-adapters PASS", + "passed": true, + "ac_id": "faff-1428#done:5", + "verifications": [ + { + "kind": "command", + "locator": "node plugin/skills/faff/bin/faff validate-adapters" + } + ] + }, + { + "ac": "The command is TypeScript (`rule-inventory.ts` with its committed `.js` emit and `build-manifest.json`), in the factory region, with `REGION_SELFTEST_ARGV[\"rule-inventory\"] = null`, a `TEST_FILE_COVERAGE` entry for `test/rule-inventory.test.mjs`, and a `docs/guide/cli.md` row; `faff regions check`, `faff lint-cli-coverage` and `faff lint-cli-doc` pass.", + "verified": "rule-inventory.ts, committed .js emit and build-manifest; factory region, selftest null, TEST_FILE_COVERAGE, cli.md row; all three lints PASS", + "passed": true, + "ac_id": "faff-1428#done:6", + "verifications": [ + { + "kind": "command", + "locator": "node plugin/skills/faff/bin/faff regions check" + }, + { + "kind": "command", + "locator": "node plugin/skills/faff/bin/faff lint-cli-coverage" + }, + { + "kind": "command", + "locator": "node plugin/skills/faff/bin/faff lint-cli-doc" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::the CLI registration: a factory command with no selftest" + } + ] + }, + { + "ac": "A row's anchor covers a block only when it is at least 20 characters and a substring of the block's normalised deleted text, or when it equals that whole text; a short anchor never covers a longer block containing it.", + "verified": "short anchor covers only equal text; the floor mutation flips the case", + "passed": true, + "ac_id": "faff-1428#done:7", + "verifications": [ + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::a short anchor covers only a block whose whole text it equals, never a longer block containing it" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::row errors: no code span, short anchor, bad disposition, empty reason, bad or missing test path, absent test name" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::mutations: dropping the anchor-length floor, the kept-needs-test rule or the moved test each fail a case" + } + ] + }, + { + "ac": "A deleted block whose text sits inside a single head block of any watched file counts as moved, with no row needed, including a reflow that leaves some lines unchanged.", + "verified": "cross-file move and partial reflow counted moved; the moved-test mutation flips", + "passed": true, + "ac_id": "faff-1428#done:8", + "verifications": [ + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::text moved verbatim into another skill file, or reflowed in place, is moved and needs no row" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::mutations: dropping the anchor-length floor, the kept-needs-test rule or the moved test each fail a case" + } + ] + }, + { + "ac": "The watched set is `prose-size`'s exported `isCovered`, imported, not a second list.", + "verified": "isCovered imported from ./prose-size; no second list", + "passed": true, + "ac_id": "faff-1428#done:9", + "verifications": [ + { + "kind": "command", + "locator": "grep -n 'isCovered' plugin/skills/faff/bin/lib/rule-inventory.ts" + } + ] + }, + { + "ac": "Path listing uses `-z` with `core.quotePath=false`; a watched-looking path the parser cannot decode exits 2.", + "verified": "diff -z --name-status under core.quotePath=false; an undecodable plugin/skills/ path throws (exit 2); per-file diff uses --text and --literal-pathspecs, a hunkless or Binary diff exits 2; a mode-only change or empty-base deletion deletes nothing, any other hunkless content change exits 2", + "passed": true, + "ac_id": "faff-1428#done:10", + "verifications": [ + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::a changed path under plugin/skills/ that is not valid UTF-8 is an error, never a skip" + }, + { + "kind": "command", + "locator": "grep -n 'core.quotePath=false' plugin/skills/faff/bin/lib/rule-inventory.ts" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::a binary or -diff attribute, or a NUL byte at HEAD, never hides a deletion (review: binary diff)" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::a watched file whose diff has no text hunk is an error, never a skip (review: binary diff)" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::a watched path with glob characters is matched literally (review: literal pathspecs)" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::a mode-only change or an empty watched file's deletion deletes nothing; a content change with no hunk still exits 2 (review round 2: no-hunk guard)" + } + ] + }, + { + "ac": "A move into a non-watched file is not moved.", + "verified": "a move into docs/guide/prep.md is uncovered", + "passed": true, + "ac_id": "faff-1428#done:11", + "verifications": [ + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::text moved into a non-watched file is not moved" + } + ] + }, + { + "ac": "Table separator rows and letterless blocks are exempt; a table header row is exempt only when its whole table is deleted, and a heading only when its whole section is deleted.", + "verified": "separator and letterless exempt; header exempt only with its whole table; heading only with its whole section; exemption needs every line deleted by pure-deletion hunks, a lettered block, and no reappearance; an empty section is never exempt; a heading deleted alone over an edited paragraph is not exempt", + "passed": true, + "ac_id": "faff-1428#done:12", + "verifications": [ + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::a deleted thematic break alone is letterless and exempt" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::exemptions: separator rows are exempt; a header row is exempt only with its whole table" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::a whole section deleted counts only its paragraph: the heading is exempt" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::a reworded heading alone is not exempt: it needs a row" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::a reworded heading over a partly edited section is not exempt (review: section exemption)" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::a renamed table header is not exempt when the table's rows reappear (review: table exemption)" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::a section moved to another skill under a new heading is not exempt: its old heading needs a row (review: section exemption)" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::a heading with an empty section is not exempt when deleted (review: empty section)" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::a heading deleted alone while its one-line paragraph is edited is not exempt (review round 2: pure members)" + } + ] + }, + { + "ac": "Normalise deletes `*` and backticks and collapses whitespace, on anchors, deleted text and head blocks alike.", + "verified": "normalise strips * and backticks and collapses whitespace", + "passed": true, + "ac_id": "faff-1428#done:13", + "verifications": [ + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::normalise, clean and mdEscape" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::a qualified header (`Prose rule (\u00a72)`) is read; a kept row in a table with no Covering test column is a row error" + } + ] + }, + { + "ac": "Only specs the diff adds are read; a modified older spec contributes no rows.", + "verified": "a modified older spec contributes no rows; ./records/specs/ normalised, absolute path exits 2", + "passed": true, + "ac_id": "faff-1428#done:14", + "verifications": [ + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::only specs the diff adds are read: an older spec the branch modifies contributes no rows" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::spec_docs_path is normalised: ./records/specs/ finds the spec; an absolute path exits 2 (review: spec_docs_path)" + } + ] + }, + { + "ac": "A diff that adds or modifies a path under `.faff/anchors/` but commits no spec fails with `footprint_without_spec: true`.", + "verified": "added and modified anchor paths both fail with footprint_without_spec", + "passed": true, + "ac_id": "faff-1428#done:15", + "verifications": [ + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::an added or modified .faff/anchors/ path with deleted prose and no spec fails with footprint_without_spec" + } + ] + }, + { + "ac": "`--summary` escapes markdown metacharacters and HTML in every spec-derived value.", + "verified": "mdEscape escapes metacharacters and HTML; no unescaped ![ or pipe", + "passed": true, + "ac_id": "faff-1428#done:16", + "verifications": [ + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::normalise, clean and mdEscape" + } + ] + }, + { + "ac": "A covering-test path outside `^test/\u2026\\.test\\.mjs$` or with a `..` segment is a row error and is never looked up.", + "verified": "src/a.js and test/../escape.test.mjs are row errors and never looked up", + "passed": true, + "ac_id": "faff-1428#done:17", + "verifications": [ + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::row errors: no code span, short anchor, bad disposition, empty reason, bad or missing test path, absent test name" + } + ] + }, + { + "ac": "Every printed line starts with `rule-inventory: `; spec-derived values are stripped of control characters and capped at 200 characters; a committed spec over 1 MiB exits 2.", + "verified": "every stdout line prefixed; ::error:: never starts a line; clean() strips controls and caps at 200; a spec over 1 MiB exits 2", + "passed": true, + "ac_id": "faff-1428#done:18", + "verifications": [ + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::a spec-derived workflow command never starts an output line" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::a committed spec over 1 MiB exits 2" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::normalise, clean and mdEscape" + } + ] + }, + { + "ac": "`--summary FILE` appends the row-covered table, `dropped` and `changed` first, and the CI step passes `$GITHUB_STEP_SUMMARY`.", + "verified": "summary appends, dropped first; the CI step passes $GITHUB_STEP_SUMMARY", + "passed": true, + "ac_id": "faff-1428#done:19", + "verifications": [ + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::--summary appends the row-covered table with dropped rows first" + }, + { + "kind": "command", + "locator": "grep -n 'GITHUB_STEP_SUMMARY' .github/workflows/validate.yml" + } + ] + }, + { + "ac": "A row whose anchor matches no deleted block produces the warning `anchor matches no deleted prose` and does not fail the check.", + "verified": "warning emitted, exit 0", + "passed": true, + "ac_id": "faff-1428#done:20", + "verifications": [ + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::a row whose anchor matches no deleted prose warns and does not fail" + } + ] + }, + { + "ac": "`kept` rows with no test, `changed`/`dropped` rows with no reason, and rows naming a missing test file or a test name absent from the file are row errors.", + "verified": "kept with no test, empty reason, missing file and absent name are all row errors; a directory is not a covering test (cat-file -t blob); covering-test lookup is ls-tree through the failure guard and must be a blob", + "passed": true, + "ac_id": "faff-1428#done:21", + "verifications": [ + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::a kept row with an empty Covering test cell is a row error" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::row errors: no code span, short anchor, bad disposition, empty reason, bad or missing test path, absent test name" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::a covering test that is a directory at HEAD does not exist (review: cat-file -t)" + } + ] + }, + { + "ac": "A PR that commits no spec is `skipped` with `no-spec-committed`; one that deletes no non-exempt prose is `skipped` with `no-prose-deleted`.", + "verified": "no-spec-committed and no-prose-deleted skips", + "passed": true, + "ac_id": "faff-1428#done:22", + "verifications": [ + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::deleted prose with no spec committed is skipped with no-spec-committed and the fixed notice" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::a deleted thematic break alone is letterless and exempt" + } + ] + }, + { + "ac": "Without `--base`, the base is the merge-base with `origin/HEAD`, or the result is `skipped` with `no-base`.", + "verified": "no origin/HEAD gives no-base; with origin/HEAD the merge-base; a failing origin/HEAD lookup exits 2, never a no-base skip; merge-base takes refs/remotes/origin/HEAD", + "passed": true, + "ac_id": "faff-1428#done:23", + "verifications": [ + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::without --base: merge-base with origin/HEAD, else skipped no-base" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::without --base, a failing origin/HEAD lookup is an error, not a no-base skip (holdout scenarios:21)" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::without --base, the merge-base uses the full refs/remotes/origin/HEAD, never a local branch named origin/HEAD (review round 3)" + } + ] + }, + { + "ac": "A failing git command, an unreadable committed spec, or a bad `--base` exits 2, never 0.", + "verified": "git failures (diff, cat-file, ls-tree), a bad ref and an unreadable config all exit 2; hunkless diff and absolute spec_docs_path exit 2; a lost main-checkout config fallback exits 2; a lost overlay fallback exits 2; from a passing run every injected git failure exits 2 with empty stdout, and the shared main-checkout probe prints the dispatch line naming the root and git's message (FAFF-1463)", + "passed": true, + "ac_id": "faff-1428#done:24", + "verifications": [ + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::a failing git command exits 2, never 0" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::an unreadable config exits 2, never a pass" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::a --base that is not a plain ref exits 2 and prints nothing on stdout" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::a watched file whose diff has no text hunk is an error, never a skip (review: binary diff)" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::spec_docs_path is normalised: ./records/specs/ finds the spec; an absolute path exits 2 (review: spec_docs_path)" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::a failed main-checkout lookup in the shared config resolution exits 2, never a no-spec skip (holdout scenarios:21)" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::a lost overlay fallback in a linked worktree exits 2 (FAFF-1463 shared lookup)" + }, + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::from a passing run, failing any single git call exits 2, with and without --base (holdout scenarios:21)" + } + ] + }, + { + "ac": "`validate.yml` runs the check on `pull_request` with `--base origin/$BASE_REF` and on `push` with `BEFORE`, with event values reaching the script only through `env`, an unfetchable `BEFORE` failing the step, and the literal no-base line present for `faff gates discover`.", + "verified": "pull_request --base origin/$BASE_REF, push --base $BEFORE, env-only values, unfetchable BEFORE exits 1, literal no-base line discovered as a STATIC_ANALYSIS rung", + "passed": true, + "ac_id": "faff-1428#done:25", + "verifications": [ + { + "kind": "command", + "locator": "node plugin/skills/faff/bin/faff gates discover" + }, + { + "kind": "command", + "locator": "grep -n 'rule-inventory check' .github/workflows/validate.yml" + } + ] + }, + { + "ac": "`test/rule-inventory.test.mjs` covers each scenario in section 5, each row-error kind and each exemption rule (separator, letterless, header row with and without its whole table, heading with and without its whole section), using fixture repos with a local bare origin, and passes under `node --test`.", + "verified": "45 cases pass over fixture repos with a local bare origin: builder-view scenarios, every row-error kind and every exemption rule", + "passed": true, + "ac_id": "faff-1428#done:26", + "verifications": [ + { + "kind": "command", + "locator": "node --import ./test/hermetic-env.mjs --test test/rule-inventory.test.mjs" + } + ] + }, + { + "ac": "A mutation test shows each check bites: removing the anchor-length floor, the kept-needs-test rule, or the moved test each makes at least one case in the file fail.", + "verified": "three mutants (floor, kept-needs-test, moved) each fail a case", + "passed": true, + "ac_id": "faff-1428#done:27", + "verifications": [ + { + "kind": "test", + "locator": "test/rule-inventory.test.mjs::mutations: dropping the anchor-length floor, the kept-needs-test rule or the moved test each fail a case" + } + ] + } + ] +} diff --git a/.faff/anchors/run-20261104-043908-graft-FAFF-1428/FAFF-1428/build-progress.json b/.faff/anchors/run-20261104-043908-graft-FAFF-1428/FAFF-1428/build-progress.json new file mode 100644 index 000000000..721f40ab3 --- /dev/null +++ b/.faff/anchors/run-20261104-043908-graft-FAFF-1428/FAFF-1428/build-progress.json @@ -0,0 +1,12 @@ +{ + "schema_version": 1, + "faff_version": "0.44.0", + "issue": "FAFF-1428", + "build": { + "status": "complete", + "diff_hash": "7c89f968d59c4d33d4e9349004d5bc816617278d282fc160dda4a89262d95792", + "branch": "FAFF-1428-rule-inventory-check", + "pushed_at": "2026-11-05T04:30:48.962Z" + }, + "updated_at": "2026-11-05T04:30:48.962Z" +} diff --git a/.faff/anchors/run-20261104-043908-graft-FAFF-1428/FAFF-1428/chain-head.json b/.faff/anchors/run-20261104-043908-graft-FAFF-1428/FAFF-1428/chain-head.json new file mode 100644 index 000000000..154429863 --- /dev/null +++ b/.faff/anchors/run-20261104-043908-graft-FAFF-1428/FAFF-1428/chain-head.json @@ -0,0 +1,8 @@ +{ + "run_id": "run-20261104-043908-graft-FAFF-1428", + "issue": "FAFF-1428", + "head_seq": 6, + "head_sha256": "a1921f1cd041c65ea65be8b579e98086c42b5b2de53580e4eb5e1d9c183e46e3", + "line_count": 7, + "schema_floor": 2 +} diff --git a/.faff/anchors/run-20261104-043908-graft-FAFF-1428/FAFF-1428/commissaire/producer/pk.json b/.faff/anchors/run-20261104-043908-graft-FAFF-1428/FAFF-1428/commissaire/producer/pk.json new file mode 100644 index 000000000..c4eca2f2e --- /dev/null +++ b/.faff/anchors/run-20261104-043908-graft-FAFF-1428/FAFF-1428/commissaire/producer/pk.json @@ -0,0 +1,4 @@ +{ + "pk": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAeneVFe/XteOJKXLj4elrU+SKRykLHIIrOzYr05EAdL0=\n-----END PUBLIC KEY-----\n", + "pk_fingerprint": "63fb9792d84045accb7add6724d4dbb0c73941627e7e5246d46bf2e101bd9ac9" +} diff --git a/.faff/anchors/run-20261104-043908-graft-FAFF-1428/FAFF-1428/declared-effects.jsonl b/.faff/anchors/run-20261104-043908-graft-FAFF-1428/FAFF-1428/declared-effects.jsonl new file mode 100644 index 000000000..a1c076141 --- /dev/null +++ b/.faff/anchors/run-20261104-043908-graft-FAFF-1428/FAFF-1428/declared-effects.jsonl @@ -0,0 +1,25 @@ +{"schema":3,"run_id":"run-20261104-043908-graft-FAFF-1428","seq":0,"ts":"2026-11-04T04:39:08.514Z","author":"commissaire","producer_id":"run-20261104-043908-graft-FAFF-1428","contract_revision":"faff-runner-v1","kind_of_entry":"admission","unit_id":"-","step":"admit","prev":"7edf13ff84671efe8bddedc5d97da3fcece561b33b8dec1a73b612e5c60e1de0","payload":{"producer_id":"run-20261104-043908-graft-FAFF-1428","contract_revision":"faff-runner-v1","admitted_scope":["merge","branch-delete","pr-create","push","label-write","file-write","tracker-write"],"pk_fingerprint":"63fb9792d84045accb7add6724d4dbb0c73941627e7e5246d46bf2e101bd9ac9","admitted_at":"2026-11-04T04:39:08.499Z"},"commissaire_sig":"snkoxWClnnpADFEhH6lll3avfsEniLiiCqiBYxP3v1mIiicnhI0ZDODe+Y/tQYsyIQZ2jmqBu4A8XMHVP7SPDw=="} +{"schema":2,"run_id":"run-20261104-043908-graft-FAFF-1428","seq":1,"ts":"2026-11-04T04:39:38.622Z","kind_of_entry":"declare","issue":"FAFF-1428","step":"tracker-write","effect":{"kind":"tracker-write","target":"FAFF-1428:Todo->In Progress","reversible":true},"prev":"10e4f3bd9d000ddab7e7789b2fa99204db2038530d1fb11cc0973fc24162aab8"} +{"schema":2,"run_id":"run-20261104-043908-graft-FAFF-1428","seq":2,"ts":"2026-11-04T04:39:38.679Z","kind_of_entry":"declare","issue":"FAFF-1428","step":"label-write","effect":{"kind":"label-write","target":"FAFF-1428:faff-claimed","reversible":true},"prev":"76633127c6c12293a5f7c06312dbdf63022eccddd3a83357cdc4da37d76ca54d"} +{"schema":2,"run_id":"run-20261104-043908-graft-FAFF-1428","seq":3,"ts":"2026-11-04T04:39:48.891Z","kind_of_entry":"observe","issue":"FAFF-1428","step":"tracker-write","effect":{"kind":"tracker-write","target":"FAFF-1428:Todo->In Progress","reversible":true},"prev":"01262a3d2b51abc738b02008ed34501d4fcbb3ea96be1f95b2b752c833c5c04c"} +{"schema":2,"run_id":"run-20261104-043908-graft-FAFF-1428","seq":4,"ts":"2026-11-04T04:39:48.947Z","kind_of_entry":"observe","issue":"FAFF-1428","step":"label-write","effect":{"kind":"label-write","target":"FAFF-1428:faff-claimed","reversible":true},"prev":"cbf44629fc760d3b68e83a0c3f0d1ba1a173d58bb687031689eb6601894280c0"} +{"schema":2,"run_id":"run-20261104-043908-graft-FAFF-1428","seq":5,"ts":"2026-11-04T05:11:45.510Z","kind_of_entry":"declare","issue":"FAFF-1428","step":"push","effect":{"kind":"push","target":"FAFF-1428-rule-inventory-check","reversible":true},"prev":"88b4337317be5be2ca524a27c8d29af47a06a33df87dfb276efd8fd8fe170227"} +{"schema":2,"run_id":"run-20261104-043908-graft-FAFF-1428","seq":6,"ts":"2026-11-04T05:11:48.052Z","kind_of_entry":"observe","issue":"FAFF-1428","step":"push","effect":{"kind":"push","target":"FAFF-1428-rule-inventory-check","reversible":true},"prev":"a8946ef5ff3a833138065c11a0bd1fa7ebbc1b573a541e110543a23bbe45e533"} +{"schema":2,"run_id":"run-20261104-043908-graft-FAFF-1428","seq":7,"ts":"2026-11-04T05:31:40.938Z","kind_of_entry":"declare","issue":"FAFF-1428","step":"push","effect":{"kind":"push","target":"FAFF-1428-rule-inventory-check","reversible":true},"prev":"49f7920dd05634e4f43b4bbe50bf54995ed1f8e08def489d65b8424392a90701"} +{"schema":2,"run_id":"run-20261104-043908-graft-FAFF-1428","seq":8,"ts":"2026-11-04T05:31:43.013Z","kind_of_entry":"observe","issue":"FAFF-1428","step":"push","effect":{"kind":"push","target":"FAFF-1428-rule-inventory-check","reversible":true},"prev":"0de3f33dd2a8491d7f40e57b009bafe13ac07dab30038d47689c29024c6a934f"} +{"schema":2,"run_id":"run-20261104-043908-graft-FAFF-1428","seq":9,"ts":"2026-11-04T05:45:35.790Z","kind_of_entry":"declare","issue":"FAFF-1428","step":"push","effect":{"kind":"push","target":"FAFF-1428-rule-inventory-check","reversible":true},"prev":"63619f5de2d594703d4f570bb986125d3f2af07dcbae2690ff64329ca8a8f65f"} +{"schema":2,"run_id":"run-20261104-043908-graft-FAFF-1428","seq":10,"ts":"2026-11-04T05:45:37.574Z","kind_of_entry":"observe","issue":"FAFF-1428","step":"push","effect":{"kind":"push","target":"FAFF-1428-rule-inventory-check","reversible":true},"prev":"e766ea31affc4af5fd00e9e5ef7cdfed3e27d8382fd6f324f2ac8b97391fda1d"} +{"schema":2,"run_id":"run-20261104-043908-graft-FAFF-1428","seq":11,"ts":"2026-11-04T05:57:40.763Z","kind_of_entry":"declare","issue":"FAFF-1428","step":"push","effect":{"kind":"push","target":"FAFF-1428-rule-inventory-check","reversible":true},"prev":"1167dc8b014fdbce0b873d6022956d2ae6d8e30e2ef692f7df97b05b2b528824"} +{"schema":2,"run_id":"run-20261104-043908-graft-FAFF-1428","seq":12,"ts":"2026-11-04T05:57:42.961Z","kind_of_entry":"observe","issue":"FAFF-1428","step":"push","effect":{"kind":"push","target":"FAFF-1428-rule-inventory-check","reversible":true},"prev":"af8935dab2e6841d89a919f7f5b9f38e66f21686f8a51352285466b62fc78166"} +{"schema":2,"run_id":"run-20261104-043908-graft-FAFF-1428","seq":13,"ts":"2026-11-04T21:53:07.058Z","kind_of_entry":"declare","issue":"FAFF-1428","step":"push","effect":{"kind":"push","target":"FAFF-1428-rule-inventory-check","reversible":true},"prev":"3193be197fdb930e2e2992fd25b42b3684b1a3911b815e66acef815bede2dcbf"} +{"schema":2,"run_id":"run-20261104-043908-graft-FAFF-1428","seq":14,"ts":"2026-11-04T21:53:09.884Z","kind_of_entry":"observe","issue":"FAFF-1428","step":"push","effect":{"kind":"push","target":"FAFF-1428-rule-inventory-check","reversible":true},"prev":"ebbd4471a49d7153d86f2566712739266206bb3040c0693b3c95bd7fddef45d3"} +{"schema":2,"run_id":"run-20261104-043908-graft-FAFF-1428","seq":15,"ts":"2026-11-05T03:58:17.347Z","kind_of_entry":"declare","issue":"FAFF-1428","step":"push","effect":{"kind":"push","target":"FAFF-1428-rule-inventory-check","reversible":true},"prev":"54f3a089ead528b1c48d364d4cded51f4fc862d16bc9e29bd9ff922fbd168ede"} +{"schema":2,"run_id":"run-20261104-043908-graft-FAFF-1428","seq":16,"ts":"2026-11-05T03:58:20.751Z","kind_of_entry":"observe","issue":"FAFF-1428","step":"push","effect":{"kind":"push","target":"FAFF-1428-rule-inventory-check","reversible":true},"prev":"08fb954999f53f6c6db31dfcf9ee6fb6fc759adf90210c33c3d509c819385722"} +{"schema":2,"run_id":"run-20261104-043908-graft-FAFF-1428","seq":17,"ts":"2026-11-05T04:10:02.103Z","kind_of_entry":"declare","issue":"FAFF-1428","step":"push","effect":{"kind":"push","target":"FAFF-1428-rule-inventory-check","reversible":true},"prev":"490d71f061af7beab14097a62ddd8c845e6a8922174cc56fcd6596e4980abff3"} +{"schema":2,"run_id":"run-20261104-043908-graft-FAFF-1428","seq":18,"ts":"2026-11-05T04:10:04.562Z","kind_of_entry":"observe","issue":"FAFF-1428","step":"push","effect":{"kind":"push","target":"FAFF-1428-rule-inventory-check","reversible":true},"prev":"790e1878205e4fabccb08c1138bf6e1b1b74a74bce898ab981c48872bb640447"} +{"schema":3,"run_id":"run-20261104-043908-graft-FAFF-1428","seq":19,"ts":"2026-11-05T04:10:07.479Z","author":"producer","producer_id":"run-20261104-043908-graft-FAFF-1428","contract_revision":"faff-runner-v1","kind_of_entry":"declare","unit_id":"FAFF-1428","step":"pr-create","prev":"2b293795e1414e39e7dea52be57f0e025cdf10b57e88ea4be9aafc0c4012b51d","effect":{"kind":"pr-create","target":"main","reversible":true},"producer_hmac":"7d4596d8b09eefa0cec2fed37b9bbfbf05fafa738d0358d183f73149a093fa3b"} +{"schema":3,"run_id":"run-20261104-043908-graft-FAFF-1428","seq":20,"ts":"2026-11-05T04:10:07.547Z","author":"producer","producer_id":"run-20261104-043908-graft-FAFF-1428","contract_revision":"faff-runner-v1","kind_of_entry":"effect-decision-request","unit_id":"FAFF-1428","step":"pr-create","prev":"1bc8c048ca7d8c3792622ba6e268a9c882a01da17240c5b96224c0486f9f1b3e","payload":{"effect":{"kind":"pr-create","target":"main"},"declared_ref":null,"level":"L2","attended":true,"holdout":"meets-spec"},"producer_hmac":"2a014827e8d2512e315ac793d7b9dd48610de7d993a2a0c43eea2a548e342216"} +{"schema":3,"run_id":"run-20261104-043908-graft-FAFF-1428","seq":21,"ts":"2026-11-05T04:10:07.550Z","author":"commissaire","producer_id":"run-20261104-043908-graft-FAFF-1428","contract_revision":"faff-runner-v1","kind_of_entry":"effect-decision-verdict","unit_id":"FAFF-1428","step":"pr-create","prev":"156f6fdb7ec26522babb7f8c22847ff20d4017980f3b299e5e15d3d9308f1a7d","payload":{"request_seq":20,"verdict":"grant","reason":"all-legs-pass","effect":{"kind":"pr-create","target":"main"},"level":"L2","attended":true,"holdout":"meets-spec","level_policy":{"pass":true,"reason":"attended"},"pure_verdict":"grant","pure_reason":"all-legs-pass"},"commissaire_sig":"TbzFwieidoBAixpQ6TY8M1rUZbN7HZn2ti5xoMu8JKVkTsJ2jFgkeek0xGXqB5UkfNCVZjbWOv83DiW3gH31Dw=="} +{"schema":3,"run_id":"run-20261104-043908-graft-FAFF-1428","seq":22,"ts":"2026-11-05T04:10:11.935Z","author":"producer","producer_id":"run-20261104-043908-graft-FAFF-1428","contract_revision":"faff-runner-v1","kind_of_entry":"observe","unit_id":"FAFF-1428","step":"pr-create","prev":"bf801c3191586e243b8cc268174ab06e474947811f8bfc2e4d89867d7d3cc2ce","effect":{"kind":"pr-create","target":"main","reversible":true},"producer_hmac":"8d081095b86a8af2ff98308e58bfbc583899077cefa04123de464a13248a48ed"} +{"schema":2,"run_id":"run-20261104-043908-graft-FAFF-1428","seq":23,"ts":"2026-11-05T04:30:45.525Z","kind_of_entry":"declare","issue":"FAFF-1428","step":"push","effect":{"kind":"push","target":"FAFF-1428-rule-inventory-check","reversible":true},"prev":"8fdce545b610e02d911dc14a48e03e11d2c0aed3e7a4e6bf596c20f8130fff5f"} +{"schema":2,"run_id":"run-20261104-043908-graft-FAFF-1428","seq":24,"ts":"2026-11-05T04:30:48.112Z","kind_of_entry":"observe","issue":"FAFF-1428","step":"push","effect":{"kind":"push","target":"FAFF-1428-rule-inventory-check","reversible":true},"prev":"40a93e05131ee5c12487334e9295780961981c42592c8c84602789b00edaf97b"} diff --git a/.faff/anchors/run-20261104-043908-graft-FAFF-1428/FAFF-1428/effects-chain-head.json b/.faff/anchors/run-20261104-043908-graft-FAFF-1428/FAFF-1428/effects-chain-head.json new file mode 100644 index 000000000..e44b02aed --- /dev/null +++ b/.faff/anchors/run-20261104-043908-graft-FAFF-1428/FAFF-1428/effects-chain-head.json @@ -0,0 +1,8 @@ +{ + "run_id": "run-20261104-043908-graft-FAFF-1428", + "issue": "FAFF-1428", + "head_seq": 24, + "head_sha256": "98b477a0313a9e19d48dac3c22bd774b4dbad147f70e75765ae204a5a0148e8e", + "line_count": 25, + "schema_floor": 2 +} diff --git a/.faff/anchors/run-20261104-043908-graft-FAFF-1428/FAFF-1428/events.jsonl b/.faff/anchors/run-20261104-043908-graft-FAFF-1428/FAFF-1428/events.jsonl new file mode 100644 index 000000000..460f96cb8 --- /dev/null +++ b/.faff/anchors/run-20261104-043908-graft-FAFF-1428/FAFF-1428/events.jsonl @@ -0,0 +1,7 @@ +{"schema":2,"run_id":"run-20261104-043908-graft-FAFF-1428","seq":0,"ts":"2026-11-04T04:39:08.374Z","prev":"7edf13ff84671efe8bddedc5d97da3fcece561b33b8dec1a73b612e5c60e1de0","phase":"run","type":"ledger-write","data":{"ledger_sha256":"fde7669c508968c948c0805e2421b4d8aac5d80981e24c5169c06334930999bf"}} +{"schema":2,"run_id":"run-20261104-043908-graft-FAFF-1428","seq":1,"ts":"2026-11-04T04:39:08.376Z","prev":"0e5f26d4b85429ab869fb22b8202507d057fdbe242f1ad3d675451a5de05bfdf","phase":"run","type":"run-start","data":{"faff_version":"0.44.0"}} +{"schema":2,"run_id":"run-20261104-043908-graft-FAFF-1428","seq":2,"ts":"2026-11-04T04:39:08.381Z","prev":"ee6de914e69fe66959673cfb5b6683178a4d5f3ae93bc2ce4955f1cf94b86d95","phase":"run","type":"runner-identity","data":{"github_login":null,"github_login_source":"unresolved","credential_source":"ambient","credential_unresolved":false,"verified":false}} +{"schema":2,"run_id":"run-20261104-043908-graft-FAFF-1428","seq":3,"ts":"2026-11-04T05:16:48.629Z","prev":"f08cb502351d5ac3a8d09b5efc211430dd11b3b2641c6dd11293de6c0826e619","phase":"run","type":"ledger-write","data":{"ledger_sha256":"22852bcb808fd8ce8af99afbe6b2baefb9aaf40a5b5720ff48e47dd2bb45bea6"}} +{"schema":2,"run_id":"run-20261104-043908-graft-FAFF-1428","seq":4,"ts":"2026-11-04T22:04:34.388Z","prev":"1b1de5e7292f72fe594f9127b5406ffe261b1e5fa9713bb3308517d0d0609e84","phase":"run","type":"ledger-write","data":{"ledger_sha256":"22852bcb808fd8ce8af99afbe6b2baefb9aaf40a5b5720ff48e47dd2bb45bea6"}} +{"schema":2,"run_id":"run-20261104-043908-graft-FAFF-1428","seq":5,"ts":"2026-11-05T04:06:10.529Z","prev":"958ce6ec24f6e44274393cc19a52c73499bff0887f50078e9ecf97747a1f35e2","phase":"run","type":"ledger-write","data":{"ledger_sha256":"22852bcb808fd8ce8af99afbe6b2baefb9aaf40a5b5720ff48e47dd2bb45bea6"}} +{"schema":2,"run_id":"run-20261104-043908-graft-FAFF-1428","seq":6,"ts":"2026-11-05T04:39:37.218Z","prev":"7868e81c68853ffbb5681f10a6083b0c70d88986afb4e72075dac68a2d716c94","phase":"run","type":"ledger-write","data":{"ledger_sha256":"22852bcb808fd8ce8af99afbe6b2baefb9aaf40a5b5720ff48e47dd2bb45bea6"}} diff --git a/.faff/anchors/run-20261104-043908-graft-FAFF-1428/FAFF-1428/holdout.json b/.faff/anchors/run-20261104-043908-graft-FAFF-1428/FAFF-1428/holdout.json new file mode 100644 index 000000000..df9b7989c --- /dev/null +++ b/.faff/anchors/run-20261104-043908-graft-FAFF-1428/FAFF-1428/holdout.json @@ -0,0 +1,524 @@ +{ + "schema_version": 1, + "faff_version": "0.44.0", + "issue": "FAFF-1428", + "aggregate": "meets-spec", + "code_blind": true, + "spawner_attested": false, + "attestation": null, + "violations": [], + "criteria": [ + { + "id": "scenarios:1", + "text": "Given a branch that deletes a paragraph from plugin/skills/faff-graft/SKILL.md\nAnd the branch commits records/specs/2026-11-04-FAFF-9-x-design.md with no \"## Rule inventory\" section\nWhen faff rule-inventory check --base origin/main --json runs\nThen it exits 1\nAnd the JSON result is \"fail\" with one uncovered entry naming plugin/skills/faff-graft/SKILL.md\nAnd row_errors names the spec with \"no ## Rule inventory section\"", + "holdout": false, + "class": "scenario", + "verdict": "met", + "evidence_present": true, + "exercised": "Branch deletes the paragraph at base line 696 of plugin/skills/faff-graft/SKILL.md ('If a wait is long enough...'); adds records/specs/2026-11-04-FAFF-9-x-design.md with no Rule inventory heading; ran --base origin/main --json", + "expected": "exit 1; result fail; one uncovered entry naming plugin/skills/faff-graft/SKILL.md; row_errors names the spec with 'no ## Rule inventory section'", + "observed": "exit 1; result \"fail\"; uncovered=[{file:plugin/skills/faff-graft/SKILL.md, base_line:696, excerpt:'If a wait is long enough...'}] (exactly one); row_errors=[{spec:records/specs/2026-11-04-FAFF-9-x-design.md, line:1, reason:'no ## Rule inventory section'}]", + "evidence_ref": "container faff-holdout-1428-f /tmp/e6/s1 via /tmp/e6/s1-5.sh", + "reason": "every Then/And clause observed as specified", + "reason_code": null + }, + { + "id": "scenarios:2", + "text": "Given a branch that deletes two bullets from plugin/skills/faff-prep/SKILL.md\nAnd commits a spec whose Rule inventory has one row per bullet, each quoting 30 characters of its bullet in a code span\nAnd each row is \"kept\" naming an existing test/x.test.mjs\nWhen faff rule-inventory check --base origin/main --json runs\nThen it exits 0\nAnd the JSON reports covered_by_row 2 and uncovered []", + "holdout": false, + "class": "scenario", + "verdict": "met", + "evidence_present": true, + "exercised": "Branch deletes two bullets of plugin/skills/faff-prep/SKILL.md (base lines 343, 345); spec rows quote exactly 30 characters of each bullet in a code span, both 'kept' naming test/x.test.mjs which the branch adds; ran --base origin/main --json (also a 31/32-char variant in /tmp/e6/s2)", + "expected": "exit 0; covered_by_row 2; uncovered []", + "observed": "exit 0; result pass; covered_by_row 2; uncovered []; row_errors []; row_covered lists base lines 343 and 345 against spec lines 9 and 10 (same for the 31/32-char variant)", + "evidence_ref": "container faff-holdout-1428-f /tmp/e6/s2b and /tmp/e6/s2 via /tmp/e6/s6-12.sh and /tmp/e6/s1-5.sh", + "reason": "every Then/And clause observed as specified", + "reason_code": null + }, + { + "id": "scenarios:3", + "text": "Given a branch that rewraps one paragraph of plugin/skills/faff/references/kernel.md across different line breaks without changing a word\nAnd commits a spec with an empty Rule inventory table\nWhen faff rule-inventory check --base origin/main --json runs\nThen it exits 0\nAnd covered_moved is 1", + "holdout": true, + "class": "scenario", + "verdict": "met", + "evidence_present": true, + "exercised": "Branch rewraps the single-line paragraph at line 7 of plugin/skills/faff/references/kernel.md into four lines at ~60 columns, no word changed; spec has an empty Rule inventory table (header+separator only); ran --base origin/main --json", + "expected": "exit 0; covered_moved 1", + "observed": "exit 0; result pass; blocks_deleted 1; covered_moved 1; covered_by_row 0; uncovered []", + "evidence_ref": "container faff-holdout-1428-f /tmp/e6/s3 via /tmp/e6/s1-5.sh", + "reason": "every Then/And clause observed as specified", + "reason_code": null + }, + { + "id": "scenarios:4", + "text": "Given a branch that deletes a bullet from plugin/skills/faff-beep-boop/SKILL.md\nAnd commits a spec whose only row quotes that bullet, is \"kept\", and names test/missing.test.mjs, which does not exist\nWhen faff rule-inventory check --base origin/main --json runs\nThen it exits 1\nAnd row_errors names test/missing.test.mjs", + "holdout": true, + "class": "scenario", + "verdict": "met", + "evidence_present": true, + "exercised": "Branch deletes the bullet at base line 21 of plugin/skills/faff-beep-boop/SKILL.md; spec's only row quotes it, 'kept', names test/missing.test.mjs (absent at HEAD, confirmed by ls); ran --base origin/main --json", + "expected": "exit 1; row_errors names test/missing.test.mjs", + "observed": "exit 1; result fail; row_errors=[{line:9, reason:'covering test test/missing.test.mjs does not exist'}]; the bullet listed as uncovered", + "evidence_ref": "container faff-holdout-1428-f /tmp/e6/s4 via /tmp/e6/s1-5.sh", + "reason": "every Then/And clause observed as specified", + "reason_code": null + }, + { + "id": "scenarios:5", + "text": "Given a branch that deletes a paragraph from plugin/skills/faff-graft/SKILL.md and commits no file under records/specs/\nWhen faff rule-inventory check --base origin/main --json runs\nThen it exits 0\nAnd skipped_reason is \"no-spec-committed\"", + "holdout": false, + "class": "scenario", + "verdict": "met", + "evidence_present": true, + "exercised": "Branch deletes the graft paragraph (base line 696) and changes only that file (name-status: M plugin/skills/faff-graft/SKILL.md); ran --base origin/main --json", + "expected": "exit 0; skipped_reason no-spec-committed", + "observed": "exit 0; result skipped; skipped_reason \"no-spec-committed\"; specs []", + "evidence_ref": "container faff-holdout-1428-f /tmp/e6/s5 via /tmp/e6/s1-5.sh", + "reason": "every Then/And clause observed as specified", + "reason_code": null + }, + { + "id": "scenarios:6", + "text": "Given a branch that deletes a bullet and commits a spec whose row for it reads \"dropped:\" with nothing after the colon\nWhen faff rule-inventory check --base origin/main runs\nThen it exits 1\nAnd the output has a line starting \"rule-inventory: row-error\" that names the spec's path and the row's line number", + "holdout": true, + "class": "scenario", + "verdict": "met", + "evidence_present": true, + "exercised": "Branch deletes the beep-boop bullet (base line 21); spec row at line 9 reads '| `spec, review, ship — passed through to the sub-skills` | dropped: | |'; ran --base origin/main (no --json)", + "expected": "exit 1; a stdout line starting 'rule-inventory: row-error' naming the spec path and row line number", + "observed": "exit 1; stdout line 'rule-inventory: row-error records/specs/2026-11-04-FAFF-9-x-design.md:9: dropped row gives no reason' (line 9 is the row's line, confirmed by grep -n); also an uncovered line and the 'rule-inventory: fail (...)' summary", + "evidence_ref": "container faff-holdout-1428-f /tmp/e6/s6 via /tmp/e6/s6-12.sh", + "reason": "every Then/And clause observed as specified", + "reason_code": null + }, + { + "id": "scenarios:7", + "text": "Given a branch that deletes a paragraph from plugin/skills/faff-graft/SKILL.md\nAnd adds .faff/anchors/FAFF-9/anchor.json but commits no file under records/specs/\nWhen faff rule-inventory check --base origin/main --json runs\nThen it exits 1\nAnd footprint_without_spec is true", + "holdout": false, + "class": "scenario", + "verdict": "met", + "evidence_present": true, + "exercised": "Branch deletes the graft paragraph and adds .faff/anchors/FAFF-9/anchor.json, no file under records/specs/ (name-status A anchor, M SKILL.md); ran --base origin/main --json", + "expected": "exit 1; footprint_without_spec true", + "observed": "exit 1; result fail; footprint_without_spec true; specs []", + "evidence_ref": "container faff-holdout-1428-f /tmp/e6/s7 via /tmp/e6/s6-12.sh", + "reason": "every Then/And clause observed as specified", + "reason_code": null + }, + { + "id": "scenarios:8", + "text": "Given a branch that moves a bullet from plugin/skills/faff-prep/SKILL.md into docs/guide/prep.md unchanged\nAnd commits a spec with an empty Rule inventory table\nWhen faff rule-inventory check --base origin/main --json runs\nThen it exits 1\nAnd the uncovered entry names plugin/skills/faff-prep/SKILL.md", + "holdout": true, + "class": "scenario", + "verdict": "met", + "evidence_present": true, + "exercised": "Branch deletes '- No noisy commits, PRs, or CI runs for planning work' from plugin/skills/faff-prep/SKILL.md and adds the same line unchanged to a new docs/guide/prep.md; spec with empty Rule inventory table; ran --base origin/main --json", + "expected": "exit 1; the uncovered entry names plugin/skills/faff-prep/SKILL.md", + "observed": "exit 1; result fail; covered_moved 0; uncovered=[{file:plugin/skills/faff-prep/SKILL.md, base_line:343, excerpt:'- No noisy commits, PRs, or CI runs for planning work'}]", + "evidence_ref": "container faff-holdout-1428-f /tmp/e6/s8 via /tmp/e6/s6-12.sh", + "reason": "every Then/And clause observed as specified", + "reason_code": null + }, + { + "id": "scenarios:9", + "text": "Given a branch that deletes a bullet and commits a spec with a valid row for it plus a second \"dropped: obsolete\" row quoting text that appears in no deleted block\nWhen faff rule-inventory check --base origin/main --json runs\nThen it exits 0\nAnd warnings contains \"anchor matches no deleted prose\"", + "holdout": false, + "class": "scenario", + "verdict": "met", + "evidence_present": true, + "exercised": "Branch deletes the beep-boop bullet; spec has a valid 'dropped: gone' row for it plus a 'dropped: obsolete' row quoting '`this sentence appears in no deleted block whatsoever`'; ran --base origin/main --json", + "expected": "exit 0; warnings contains 'anchor matches no deleted prose'", + "observed": "exit 0; result pass; covered_by_row 1; warnings=['records/specs/2026-11-04-FAFF-9-x-design.md:10: anchor matches no deleted prose']", + "evidence_ref": "container faff-holdout-1428-f /tmp/e6/s9 via /tmp/e6/s6-12.sh", + "reason": "every Then/And clause observed as specified", + "reason_code": null + }, + { + "id": "scenarios:10", + "text": "Given a branch that deletes a bullet and commits a spec whose row for it reads \"dropped: ::error::forged\"\nWhen faff rule-inventory check --base origin/main runs\nThen no line of its output starts with \"::error::\"", + "holdout": false, + "class": "scenario", + "verdict": "met", + "evidence_present": true, + "exercised": "Branch deletes the beep-boop bullet; spec row reads 'dropped: ::error::forged'; ran --base origin/main (no --json), stdout and stderr captured separately and combined", + "expected": "no line of output starts with '::error::'", + "observed": "exit 0; stdout is the single line 'rule-inventory: pass (1 deleted, 0 moved, 1 by row, 0 uncovered, 0 row error(s))'; stderr empty; grep '^::' on combined output = 0 lines; every line starts 'rule-inventory: '", + "evidence_ref": "container faff-holdout-1428-f /tmp/e6/s10 via /tmp/e6/s6-12.sh", + "reason": "every Then/And clause observed as specified", + "reason_code": null + }, + { + "id": "scenarios:11", + "text": "Given a branch that deletes two bullets and commits a spec with a \"kept\" row naming an existing test/x.test.mjs for one and a \"dropped: superseded by faff x\" row for the other\nWhen faff rule-inventory check --base origin/main --summary out.md runs\nThen it exits 0\nAnd the first table row in out.md is the dropped one", + "holdout": false, + "class": "scenario", + "verdict": "met", + "evidence_present": true, + "exercised": "Branch deletes two faff-prep bullets (base 343, 345); spec row 9 'kept' naming test/x.test.mjs (added), row 10 'dropped: superseded by faff x'; ran --base origin/main --summary out.md", + "expected": "exit 0; first table row in out.md is the dropped one", + "observed": "exit 0; out.md table header '| Disposition | Reason | Deleted block | Inventory row |', separator, then first data row '| dropped | superseded by faff x | plugin/skills/faff-prep/SKILL.md:345 | ...:10 |', then the kept row", + "evidence_ref": "container faff-holdout-1428-f /tmp/e6/s11/w/out.md via /tmp/e6/s6-12.sh", + "reason": "every Then/And clause observed as specified", + "reason_code": null + }, + { + "id": "scenarios:12", + "text": "Given a branch that deletes a bullet and commits a spec whose \"dropped:\" reason is \"x | ![p](https://example.invalid/p.png)\"\nWhen faff rule-inventory check --base origin/main --summary out.md runs\nThen out.md's table has the same number of cells in every row\nAnd out.md contains no unescaped \"![\" sequence", + "holdout": true, + "class": "scenario", + "verdict": "met", + "evidence_present": true, + "exercised": "Branch deletes the beep-boop bullet; spec row reason written per the documented format with the cell pipe escaped: 'dropped: x \\| ![p](https://example.invalid/p.png)'; ran --base origin/main --summary out.md; counted unescaped pipes per table line and unescaped '![' with a backslash-aware scanner", + "expected": "every table row of out.md has the same number of cells; no unescaped '![' in out.md", + "observed": "exit 0; table lines have 4 cells each (header, separator, data); data row reason rendered 'x \\| \\!\\[p\\]\\(https://example.invalid/p.png\\)'; unescaped '![' count 0. (Variant with a raw unescaped '|' in the spec cell splits the cell, yields a row error and exit 1, and writes no table: vacuously consistent, not the judged form)", + "evidence_ref": "container faff-holdout-1428-f /tmp/e6/s12/w/out.md and /tmp/e6/s12b via /tmp/e6/s12-20.sh", + "reason": "every Then/And clause observed as specified", + "reason_code": null + }, + { + "id": "scenarios:13", + "text": "Given a branch that deletes a bullet and commits a spec whose row for it is \"kept\" with an empty Covering test cell\nWhen faff rule-inventory check --base origin/main --json runs\nThen it exits 1\nAnd row_errors contains \"kept row names no covering test\"", + "holdout": false, + "class": "scenario", + "verdict": "met", + "evidence_present": true, + "exercised": "Branch deletes the beep-boop bullet; spec row 'kept' with an empty Covering test cell (test/x.test.mjs exists); ran --base origin/main --json", + "expected": "exit 1; row_errors contains 'kept row names no covering test'", + "observed": "exit 1; result fail; row_errors=[{line:9, reason:'kept row names no covering test'}]", + "evidence_ref": "container faff-holdout-1428-f /tmp/e6/s13 via /tmp/e6/s12-20.sh", + "reason": "every Then/And clause observed as specified", + "reason_code": null + }, + { + "id": "scenarios:14", + "text": "Given a branch that changes the text of one heading in plugin/skills/faff-graft/SKILL.md and nothing else\nAnd commits a spec with an empty Rule inventory table\nWhen faff rule-inventory check --base origin/main --json runs\nThen it exits 1\nAnd the uncovered entry is the heading's base line", + "holdout": false, + "class": "scenario", + "verdict": "met", + "evidence_present": true, + "exercised": "Branch changes only the heading at base line 688 of plugin/skills/faff-graft/SKILL.md ('### How to actually wait for CI' -> '### How to wait for CI'); diff -U0 shows one hunk @@ -688 +688 @@; spec with empty Rule inventory table; ran --base origin/main --json", + "expected": "exit 1; the uncovered entry is the heading's base line", + "observed": "exit 1; result fail; uncovered=[{file:plugin/skills/faff-graft/SKILL.md, base_line:688, excerpt:'### How to actually wait for CI'}]", + "evidence_ref": "container faff-holdout-1428-f /tmp/e6/s14 via /tmp/e6/s12-20.sh", + "reason": "every Then/And clause observed as specified", + "reason_code": null + }, + { + "id": "scenarios:15", + "text": "Given a branch that deletes a bullet and commits a spec larger than 1 MiB\nWhen faff rule-inventory check --base origin/main runs\nThen it exits 2", + "holdout": false, + "class": "scenario", + "verdict": "met", + "evidence_present": true, + "exercised": "Branch deletes the beep-boop bullet; committed spec is 1,170,200 bytes (> 1 MiB) with a valid row; ran --base origin/main", + "expected": "exit 2", + "observed": "exit 2; stdout empty; stderr 'rule-inventory: committed spec records/specs/2026-11-04-FAFF-9-x-design.md is larger than 1 MiB' followed by the usage lines", + "evidence_ref": "container faff-holdout-1428-f /tmp/e6/s15 via /tmp/e6/s12-20.sh", + "reason": "every Then/And clause observed as specified", + "reason_code": null + }, + { + "id": "scenarios:16", + "text": "Given a branch that deletes one data row of a three-row table in plugin/skills/faff-prep/SKILL.md and leaves its header and separator\nAnd commits a spec with an empty Rule inventory table\nWhen faff rule-inventory check --base origin/main --json runs\nThen it exits 1\nAnd uncovered has exactly one entry, the deleted data row", + "holdout": true, + "class": "scenario", + "verdict": "met", + "evidence_present": true, + "exercised": "Branch deletes data row '| `surface` | Surface the outage (interactive) |' (base line 255) of the three-data-row action table in plugin/skills/faff-prep/SKILL.md, keeping header and separator; spec with empty Rule inventory table; ran --base origin/main --json", + "expected": "exit 1; uncovered has exactly one entry, the deleted data row", + "observed": "exit 1; result fail; blocks_deleted 1; uncovered=[{file:plugin/skills/faff-prep/SKILL.md, base_line:255, excerpt:'| `surface` | Surface the outage (interactive) |'}] (exactly one)", + "evidence_ref": "container faff-holdout-1428-f /tmp/e6/s16 via /tmp/e6/s12-20.sh", + "reason": "every Then/And clause observed as specified", + "reason_code": null + }, + { + "id": "scenarios:17", + "text": "Given a branch that deletes a whole \"### Example\" section of plugin/skills/faff-graft/SKILL.md: its heading and one paragraph\nAnd commits a spec whose one row quotes 30 characters of the paragraph, \"dropped: obsolete example\"\nWhen faff rule-inventory check --base origin/main --json runs\nThen it exits 0\nAnd blocks_deleted is 1", + "holdout": false, + "class": "scenario", + "verdict": "met", + "evidence_present": true, + "exercised": "Origin main seeded with a '### Example' section (heading + one paragraph) appended to plugin/skills/faff-graft/SKILL.md (base = seeded commit, origin/HEAD = origin/main); branch deletes the heading and paragraph; spec row quotes 35 chars of the paragraph ('walks a hypothetical ticket through'), 'dropped: obsolete example'; ran --base origin/main --json. Control: deleting only the heading fails with the heading uncovered", + "expected": "exit 0; blocks_deleted 1", + "observed": "exit 0; result pass; blocks_deleted 1; covered_by_row 1 (base_line 805, dropped, 'obsolete example'). Control run: exit 1 with uncovered '### Example' at base line 803", + "evidence_ref": "container faff-holdout-1428-f /tmp/e6/s17, /tmp/e6/s17b via /tmp/e6/s17-20.sh", + "reason": "every Then/And clause observed as specified", + "reason_code": null + }, + { + "id": "scenarios:18", + "text": "Given a branch that deletes one line of only \"---\" from plugin/skills/faff-graft/SKILL.md\nAnd commits a spec with no Rule inventory section\nWhen faff rule-inventory check --base origin/main --json runs\nThen it exits 0\nAnd skipped_reason is \"no-prose-deleted\"", + "holdout": false, + "class": "scenario", + "verdict": "met", + "evidence_present": true, + "exercised": "Branch deletes the line-4 '---' (closing frontmatter fence) of plugin/skills/faff-graft/SKILL.md, diff -U0 shows only '----' at @@ -4; spec with no Rule inventory section; ran --base origin/main --json. Variant: base seeded with a body '---' rule between blank lines, branch deletes it", + "expected": "exit 0; skipped_reason no-prose-deleted", + "observed": "exit 0; result skipped; skipped_reason \"no-prose-deleted\"; blocks_deleted 0 (both the frontmatter and body-rule variants)", + "evidence_ref": "container faff-holdout-1428-f /tmp/e6/s18, /tmp/e6/s18b via /tmp/e6/s17-20.sh, /tmp/e6/s18b.sh", + "reason": "every Then/And clause observed as specified", + "reason_code": null + }, + { + "id": "scenarios:19", + "text": "Given a branch that deletes two bullets, \"- Fails loud.\" and a longer bullet containing \"Fails loud.\" among other words\nAnd commits a spec whose only row has the anchor \"- Fails loud.\" and \"dropped: merged\"\nWhen faff rule-inventory check --base origin/main --json runs\nThen it exits 1\nAnd the uncovered entry is the longer bullet", + "holdout": false, + "class": "scenario", + "verdict": "met", + "evidence_present": true, + "exercised": "Base seeded with bullets '- Fails loud.' (line 803) and '- The checker Fails loud. whenever git is unreachable or the config is unreadable.' (line 804) in faff-graft SKILL.md; branch deletes both; spec's only row has anchor `- Fails loud.` and 'dropped: merged'; ran --base origin/main --json", + "expected": "exit 1; the uncovered entry is the longer bullet", + "observed": "exit 1; result fail; covered_by_row 1 (base_line 803, the short bullet); uncovered=[{base_line:804, excerpt:'- The checker Fails loud. whenever git is unreachable ...'}] only", + "evidence_ref": "container faff-holdout-1428-f /tmp/e6/s19 via /tmp/e6/s17-20.sh", + "reason": "every Then/And clause observed as specified", + "reason_code": null + }, + { + "id": "scenarios:20", + "text": "Given faff rule-inventory check is run with --base \"-x\"\nWhen it runs\nThen it exits 2\nAnd nothing is printed on stdout", + "holdout": false, + "class": "scenario", + "verdict": "met", + "evidence_present": true, + "exercised": "Ran faff rule-inventory check --base \"-x\" (also --base=-x and with --json) in a fixture repo, stdout and stderr captured separately", + "expected": "exit 2; nothing on stdout", + "observed": "exit 2 in all three forms; stdout 0 bytes; stderr 'rule-inventory: --base -x is not a plain ref' plus usage", + "evidence_ref": "container faff-holdout-1428-f /tmp/e6/s19/w via /tmp/e6/s17-20.sh", + "reason": "every Then/And clause observed as specified", + "reason_code": null + }, + { + "id": "scenarios:21", + "text": "The check MUST NOT exit 0 when a git command it runs fails.", + "holdout": false, + "class": "assertion", + "verdict": "met", + "evidence_present": true, + "exercised": "git wrapper first on PATH (/tmp/e6/gw/git) logging every call and failing exactly one call per run (exit 128 with 'fatal: injected failure', and separately exit 1), swept over every call index of 14 fixture/mode combinations: s1 fail --json, s2b pass, s5 no-spec skip, s11 --summary, s3 moved (text mode), s7 footprint, s4 missing test, s12 summary escaping, s18 no-prose-deleted skip, s19, --base HEAD (base==HEAD skip), no --base with origin/HEAD present (--json and text), no --base with origin/HEAD deleted (confirmed rev-parse --verify --quiet origin/HEAD rc=1) — 2 to 14 git calls per run, 296 injected runs", + "expected": "never exit 0 when any git command the check runs fails", + "observed": "every one of the 296 injected-failure runs exited 2; zero runs exited 0 or 1. Includes failing rev-parse --show-toplevel, the base verify, merge-base, for-each-ref origin/HEAD probe (in the no-base absent case too), diff --name-status, diff -U0, ls-tree, cat-file --batch, cat-file -s and the covering-test ls-tree lookups", + "evidence_ref": "container faff-holdout-1428-f /tmp/e6/s21.sh, /tmp/e6/gw/git", + "reason": "every Then/And clause observed as specified", + "reason_code": null + } + ], + "delegated": [ + { + "id": "done:1", + "text": "A PR that deletes a non-exempt block of skill prose, commits a spec, and has no row or move covering the block fails `faff rule-inventory check` with exit 1, naming the file and base line.", + "holdout": false, + "verification_tier": "delegated", + "verifier": "ac-checklist", + "ac_id": "faff-1428#done:1" + }, + { + "id": "done:2", + "text": "`faff rule-inventory check [--base REF] [--json] [--summary FILE]` exists, with exits 0 / 1 / 2 as section 3 defines.", + "holdout": false, + "verification_tier": "delegated", + "verifier": "ac-checklist", + "ac_id": "faff-1428#done:2" + }, + { + "id": "done:3", + "text": "`--json` output has every `CheckResult` field.", + "holdout": false, + "verification_tier": "delegated", + "verifier": "ac-checklist", + "ac_id": "faff-1428#done:3" + }, + { + "id": "done:4", + "text": "The inventory format (section heading, `Prose rule` code-span anchor, `Covering test`, `Disposition`) is documented in `docs/reference/skill-authoring.md` under a `Rule inventory` heading, and every uncovered-block message names that anchor.", + "holdout": false, + "verification_tier": "delegated", + "verifier": "ac-checklist", + "ac_id": "faff-1428#done:4" + }, + { + "id": "done:5", + "text": "`plugin/skills/faffter-dark-nlspec/SKILL.md` gains one line telling the producer that a spec deleting skill prose carries a `## Rule inventory` in that format, and `prose-baseline.json` is raised by exactly that file's growth.", + "holdout": false, + "verification_tier": "delegated", + "verifier": "ac-checklist", + "ac_id": "faff-1428#done:5" + }, + { + "id": "done:6", + "text": "The command is TypeScript (`rule-inventory.ts` with its committed `.js` emit and `build-manifest.json`), in the factory region, with `REGION_SELFTEST_ARGV[\"rule-inventory\"] = null`, a `TEST_FILE_COVERAGE` entry for `test/rule-inventory.test.mjs`, and a `docs/guide/cli.md` row; `faff regions check`, `faff lint-cli-coverage` and `faff lint-cli-doc` pass.", + "holdout": false, + "verification_tier": "integration", + "verifier": "review-merge-floor", + "ac_id": "faff-1428#done:6" + }, + { + "id": "done:7", + "text": "A row's anchor covers a block only when it is at least 20 characters and a substring of the block's normalised deleted text, or when it equals that whole text; a short anchor never covers a longer block containing it.", + "holdout": false, + "verification_tier": "delegated", + "verifier": "ac-checklist", + "ac_id": "faff-1428#done:7" + }, + { + "id": "done:8", + "text": "A deleted block whose text sits inside a single head block of any watched file counts as moved, with no row needed, including a reflow that leaves some lines unchanged.", + "holdout": false, + "verification_tier": "delegated", + "verifier": "ac-checklist", + "ac_id": "faff-1428#done:8" + }, + { + "id": "done:9", + "text": "The watched set is `prose-size`'s exported `isCovered`, imported, not a second list.", + "holdout": false, + "verification_tier": "delegated", + "verifier": "ac-checklist", + "ac_id": "faff-1428#done:9" + }, + { + "id": "done:10", + "text": "Path listing uses `-z` with `core.quotePath=false`; a watched-looking path the parser cannot decode exits 2.", + "holdout": false, + "verification_tier": "delegated", + "verifier": "ac-checklist", + "ac_id": "faff-1428#done:10" + }, + { + "id": "done:11", + "text": "A move into a non-watched file is not moved.", + "holdout": false, + "verification_tier": "delegated", + "verifier": "ac-checklist", + "ac_id": "faff-1428#done:11" + }, + { + "id": "done:12", + "text": "Table separator rows and letterless blocks are exempt; a table header row is exempt only when its whole table is deleted, and a heading only when its whole section is deleted.", + "holdout": false, + "verification_tier": "delegated", + "verifier": "ac-checklist", + "ac_id": "faff-1428#done:12" + }, + { + "id": "done:13", + "text": "Normalise deletes `*` and backticks and collapses whitespace, on anchors, deleted text and head blocks alike.", + "holdout": false, + "verification_tier": "delegated", + "verifier": "ac-checklist", + "ac_id": "faff-1428#done:13" + }, + { + "id": "done:14", + "text": "Only specs the diff adds are read; a modified older spec contributes no rows.", + "holdout": false, + "verification_tier": "delegated", + "verifier": "ac-checklist", + "ac_id": "faff-1428#done:14" + }, + { + "id": "done:15", + "text": "A diff that adds or modifies a path under `.faff/anchors/` but commits no spec fails with `footprint_without_spec: true`.", + "holdout": false, + "verification_tier": "delegated", + "verifier": "ac-checklist", + "ac_id": "faff-1428#done:15" + }, + { + "id": "done:16", + "text": "`--summary` escapes markdown metacharacters and HTML in every spec-derived value.", + "holdout": false, + "verification_tier": "delegated", + "verifier": "ac-checklist", + "ac_id": "faff-1428#done:16" + }, + { + "id": "done:17", + "text": "A covering-test path outside `^test/…\\.test\\.mjs$` or with a `..` segment is a row error and is never looked up.", + "holdout": false, + "verification_tier": "delegated", + "verifier": "ac-checklist", + "ac_id": "faff-1428#done:17" + }, + { + "id": "done:18", + "text": "Every printed line starts with `rule-inventory: `; spec-derived values are stripped of control characters and capped at 200 characters; a committed spec over 1 MiB exits 2.", + "holdout": false, + "verification_tier": "delegated", + "verifier": "ac-checklist", + "ac_id": "faff-1428#done:18" + }, + { + "id": "done:19", + "text": "`--summary FILE` appends the row-covered table, `dropped` and `changed` first, and the CI step passes `$GITHUB_STEP_SUMMARY`.", + "holdout": false, + "verification_tier": "delegated", + "verifier": "ac-checklist", + "ac_id": "faff-1428#done:19" + }, + { + "id": "done:20", + "text": "A row whose anchor matches no deleted block produces the warning `anchor matches no deleted prose` and does not fail the check.", + "holdout": false, + "verification_tier": "delegated", + "verifier": "ac-checklist", + "ac_id": "faff-1428#done:20" + }, + { + "id": "done:21", + "text": "`kept` rows with no test, `changed`/`dropped` rows with no reason, and rows naming a missing test file or a test name absent from the file are row errors.", + "holdout": false, + "verification_tier": "delegated", + "verifier": "ac-checklist", + "ac_id": "faff-1428#done:21" + }, + { + "id": "done:22", + "text": "A PR that commits no spec is `skipped` with `no-spec-committed`; one that deletes no non-exempt prose is `skipped` with `no-prose-deleted`.", + "holdout": false, + "verification_tier": "delegated", + "verifier": "ac-checklist", + "ac_id": "faff-1428#done:22" + }, + { + "id": "done:23", + "text": "Without `--base`, the base is the merge-base with `origin/HEAD`, or the result is `skipped` with `no-base`.", + "holdout": false, + "verification_tier": "delegated", + "verifier": "ac-checklist", + "ac_id": "faff-1428#done:23" + }, + { + "id": "done:24", + "text": "A failing git command, an unreadable committed spec, or a bad `--base` exits 2, never 0.", + "holdout": false, + "verification_tier": "delegated", + "verifier": "ac-checklist", + "ac_id": "faff-1428#done:24" + }, + { + "id": "done:25", + "text": "`validate.yml` runs the check on `pull_request` with `--base origin/$BASE_REF` and on `push` with `BEFORE`, with event values reaching the script only through `env`, an unfetchable `BEFORE` failing the step, and the literal no-base line present for `faff gates discover`.", + "holdout": false, + "verification_tier": "delegated", + "verifier": "ac-checklist", + "ac_id": "faff-1428#done:25" + }, + { + "id": "done:26", + "text": "`test/rule-inventory.test.mjs` covers each scenario in section 5, each row-error kind and each exemption rule (separator, letterless, header row with and without its whole table, heading with and without its whole section), using fixture repos with a local bare origin, and passes under `node --test`.", + "holdout": false, + "verification_tier": "delegated", + "verifier": "ac-checklist", + "ac_id": "faff-1428#done:26" + }, + { + "id": "done:27", + "text": "A mutation test shows each check bites: removing the anchor-length floor, the kept-needs-test rule, or the moved test each makes at least one case in the file fail.", + "holdout": false, + "verification_tier": "delegated", + "verifier": "ac-checklist", + "ac_id": "faff-1428#done:27" + } + ] +} diff --git a/.faff/anchors/run-20261104-043908-graft-FAFF-1428/FAFF-1428/review-verdict.json b/.faff/anchors/run-20261104-043908-graft-FAFF-1428/FAFF-1428/review-verdict.json new file mode 100644 index 000000000..54ed0d38f --- /dev/null +++ b/.faff/anchors/run-20261104-043908-graft-FAFF-1428/FAFF-1428/review-verdict.json @@ -0,0 +1,559 @@ +{ + "schema_version": 1, + "faff_version": "0.44.0", + "signal": "pass", + "rounds_total": 5, + "round_history": "recorded", + "findings": [ + { + "finding_id": "plugin/skills/faff/bin/lib/rule-inventory.ts::binary or -diff attribute hides deleted prose in deletedblocks diff", + "title": "binary or -diff attribute hides deleted prose in deletedBlocks diff", + "location": "plugin/skills/faff/bin/lib/rule-inventory.ts:508", + "action": "Add --text to the per-file git diff and throw CheckError when a watched file's diff has no @@ hunk or reports Binary files.", + "location_present": true, + "action_present": true, + "severity": "major", + "source": "standard", + "model": "faffter-noon-review", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": null, + "first_round": 1, + "last_round": 1, + "occurrences": [ + { + "round": 1, + "title": "binary or -diff attribute hides deleted prose in deletedBlocks diff", + "location": "plugin/skills/faff/bin/lib/rule-inventory.ts:508", + "severity": "major", + "disposition": "open" + } + ] + }, + { + "finding_id": "plugin/skills/faff/bin/lib/rule-inventory.ts::heading and table-header exemption keyed on touched, not wholly deleted, section", + "title": "heading and table-header exemption keyed on touched, not wholly deleted, section", + "location": "plugin/skills/faff/bin/lib/rule-inventory.ts:189", + "action": "Exempt a heading or header row only when every line of every block in its section or table is deleted and the section has at least one lettered block.", + "location_present": true, + "action_present": true, + "severity": "major", + "source": "standard", + "model": "faffter-noon-review", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": null, + "first_round": 1, + "last_round": 1, + "occurrences": [ + { + "round": 1, + "title": "heading and table-header exemption keyed on touched, not wholly deleted, section", + "location": "plugin/skills/faff/bin/lib/rule-inventory.ts:189", + "severity": "major", + "disposition": "open" + } + ] + }, + { + "finding_id": "test/rule-inventory.test.mjs::empty-section heading non-exemption is unpinned by any test", + "title": "empty-section heading non-exemption is unpinned by any test", + "location": "test/rule-inventory.test.mjs", + "action": "Add a case deleting a heading directly followed by a same-level heading and assert it is uncovered.", + "location_present": true, + "action_present": true, + "severity": "minor", + "source": "standard", + "model": "faffter-noon-review", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": null, + "first_round": 1, + "last_round": 1, + "occurrences": [ + { + "round": 1, + "title": "empty-section heading non-exemption is unpinned by any test", + "location": "test/rule-inventory.test.mjs", + "severity": "minor", + "disposition": "open" + } + ] + }, + { + "finding_id": "plugin/skills/faff/bin/lib/rule-inventory.ts::covering-test existence check accepts a directory", + "title": "covering-test existence check accepts a directory", + "location": "plugin/skills/faff/bin/lib/rule-inventory.ts:548", + "action": "Require git cat-file -t HEAD:<file> to return blob.", + "location_present": true, + "action_present": true, + "severity": "minor", + "source": "standard", + "model": "faffter-noon-review", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": null, + "first_round": 1, + "last_round": 1, + "occurrences": [ + { + "round": 1, + "title": "covering-test existence check accepts a directory", + "location": "plugin/skills/faff/bin/lib/rule-inventory.ts:548", + "severity": "minor", + "disposition": "open" + } + ] + }, + { + "finding_id": "plugin/skills/faff/bin/lib/rule-inventory.ts::spec docs path not normalised before prefix match", + "title": "spec docs path not normalised before prefix match", + "location": "plugin/skills/faff/bin/lib/rule-inventory.ts:495", + "action": "Normalise with path.posix.normalize, strip a leading ./, and exit 2 on an absolute path.", + "location_present": true, + "action_present": true, + "severity": "minor", + "source": "standard", + "model": "faffter-noon-review", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": null, + "first_round": 1, + "last_round": 1, + "occurrences": [ + { + "round": 1, + "title": "spec docs path not normalised before prefix match", + "location": "plugin/skills/faff/bin/lib/rule-inventory.ts:495", + "severity": "minor", + "disposition": "open" + } + ] + }, + { + "finding_id": "plugin/skills/faff/bin/lib/rule-inventory.ts::per-file diff pathspec is not literal", + "title": "per-file diff pathspec is not literal", + "location": "plugin/skills/faff/bin/lib/rule-inventory.ts:508", + "action": "Pass --literal-pathspecs to the per-file diff.", + "location_present": true, + "action_present": true, + "severity": "minor", + "source": "standard", + "model": "faffter-noon-review", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": null, + "first_round": 1, + "last_round": 1, + "occurrences": [ + { + "round": 1, + "title": "per-file diff pathspec is not literal", + "location": "plugin/skills/faff/bin/lib/rule-inventory.ts:508", + "severity": "minor", + "disposition": "open" + } + ] + }, + { + "finding_id": "plugin/skills/faff/bin/lib/rule-inventory.ts::no-hunk error false-fires on mode-only change or empty watched file deletion", + "title": "no-hunk error false-fires on mode-only change or empty watched file deletion", + "location": "plugin/skills/faff/bin/lib/rule-inventory.ts", + "action": "Raise the no-hunk CheckError only when base and HEAD blob ids differ and the base blob is non-empty; treat equal-blob or empty-base changes as having no deleted lines.", + "location_present": true, + "action_present": true, + "severity": "minor", + "source": "standard", + "model": "faffter-noon-review", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": null, + "first_round": 2, + "last_round": 2, + "occurrences": [ + { + "round": 2, + "title": "no-hunk error false-fires on mode-only change or empty watched file deletion", + "location": "plugin/skills/faff/bin/lib/rule-inventory.ts", + "severity": "minor", + "disposition": "open" + } + ] + }, + { + "finding_id": "test/rule-inventory.test.mjs::member purity check in guardof is unpinned by any test", + "title": "member purity check in guardOf is unpinned by any test", + "location": "test/rule-inventory.test.mjs", + "action": "Add a case deleting a heading alone (blank line kept) while editing its one-line paragraph, with a row only for the paragraph, and assert the heading is uncovered.", + "location_present": true, + "action_present": true, + "severity": "minor", + "source": "standard", + "model": "faffter-noon-review", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": null, + "first_round": 2, + "last_round": 2, + "occurrences": [ + { + "round": 2, + "title": "member purity check in guardOf is unpinned by any test", + "location": "test/rule-inventory.test.mjs", + "severity": "minor", + "disposition": "open" + } + ] + }, + { + "finding_id": "plugin/skills/faff/bin/lib/shared-infra.js::shared-infra overlay fallback probe failure still exits 0 in a main checkout", + "title": "shared-infra overlay fallback probe failure still exits 0 in a main checkout", + "location": "plugin/skills/faff/bin/lib/shared-infra.js", + "action": "Make mainWorktreeRoot distinguish a git failure inside a repo from no main checkout and surface it through loadConfig, or record a human acceptance of the harmless exit 0 and narrow the claim.", + "location_present": true, + "action_present": true, + "severity": "minor", + "source": "standard", + "model": "faffter-noon-review", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": null, + "first_round": 3, + "last_round": 3, + "occurrences": [ + { + "round": 3, + "title": "shared-infra overlay fallback probe failure still exits 0 in a main checkout", + "location": "plugin/skills/faff/bin/lib/shared-infra.js", + "severity": "minor", + "disposition": "open" + } + ] + }, + { + "finding_id": "plugin/skills/faff/bin/lib/rule-inventory.ts::cat-file -t and blobid read git failure as absence", + "title": "cat-file -t and blobId read git failure as absence", + "location": "plugin/skills/faff/bin/lib/rule-inventory.ts", + "action": "Look up the test file and blob ids with git ls-tree -z HEAD -- <path> through must, treating empty output as absent and a failing call as CheckError.", + "location_present": true, + "action_present": true, + "severity": "minor", + "source": "standard", + "model": "faffter-noon-review", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": null, + "first_round": 3, + "last_round": 3, + "occurrences": [ + { + "round": 3, + "title": "cat-file -t and blobId read git failure as absence", + "location": "plugin/skills/faff/bin/lib/rule-inventory.ts", + "severity": "minor", + "disposition": "open" + } + ] + }, + { + "finding_id": "test/rule-inventory.test.mjs::overlay half of verifyconfiglocation is unpinned and the every-call test starts from a failing run", + "title": "overlay half of verifyConfigLocation is unpinned and the every-call test starts from a failing run", + "location": "test/rule-inventory.test.mjs", + "action": "Add a linked-worktree overlay-only divergence case expecting exit 2, and run the every-call enumeration from a passing scenario asserting exit 2 per injected failure.", + "location_present": true, + "action_present": true, + "severity": "minor", + "source": "standard", + "model": "faffter-noon-review", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": null, + "first_round": 3, + "last_round": 3, + "occurrences": [ + { + "round": 3, + "title": "overlay half of verifyConfigLocation is unpinned and the every-call test starts from a failing run", + "location": "test/rule-inventory.test.mjs", + "severity": "minor", + "disposition": "open" + } + ] + }, + { + "finding_id": "test/rule-inventory.test.mjs::overlay test title names the removed config guard", + "title": "overlay test title names the removed config guard", + "location": "test/rule-inventory.test.mjs:752", + "action": "Retitle to 'a lost overlay fallback in a linked worktree exits 2 (FAFF-1463 shared lookup)' and update the done:24 locator in ac-checklist.json in the same change.", + "location_present": true, + "action_present": true, + "severity": "minor", + "source": "standard", + "model": "anthropic/claude-opus-5-5", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": null, + "first_round": 4, + "last_round": 4, + "occurrences": [ + { + "round": 4, + "title": "overlay test title names the removed config guard", + "location": "test/rule-inventory.test.mjs:752", + "severity": "minor", + "disposition": "open" + } + ] + }, + { + "finding_id": "test/rule-inventory.test.mjs::every-call sweep asserts inline and stops at the first failing call", + "title": "every-call sweep asserts inline and stops at the first failing call", + "location": "test/rule-inventory.test.mjs:793", + "action": "Collect status/stdout/stderr mismatches across all n and assert once after the loop, as the pre-62220446 notTwo shape did.", + "location_present": true, + "action_present": true, + "severity": "minor", + "source": "adversarial", + "model": "openai/z-ai/glm-5.3-flash", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": null, + "first_round": 4, + "last_round": 4, + "occurrences": [ + { + "round": 4, + "title": "every-call sweep asserts inline and stops at the first failing call", + "location": "test/rule-inventory.test.mjs:793", + "severity": "minor", + "disposition": "open" + } + ] + }, + { + "finding_id": "plugin/skills/faff/bin/lib/rule-inventory.ts::verifyconfiglocation removal drops a wrong-file mismatch check", + "title": "verifyConfigLocation removal drops a wrong-file mismatch check", + "location": "plugin/skills/faff/bin/lib/rule-inventory.ts:555", + "action": "No change: keep the guard removed; the refutation evidence stands.", + "location_present": true, + "action_present": true, + "severity": "observation", + "source": "adversarial", + "model": "openai/z-ai/glm-5.3-flash", + "disposition": "refuted", + "refutation": "Guard recomputed the expectation with the same findConfigIn/findOverlayIn, the same rev-parse --git-common-dir from the same cwd/env, and the same .git-basename rule as findLocationWithMainFallback. Restored guard on FAFF-1463 with an instrumented fallback branch: branch reached in 11 layouts (deprecated name, overlay-only main, abs/relative/polluted GIT_DIR, separate-git-dir, non-ASCII path, symlinked worktree, config-is-dir), never refused; outputs identical with and without guard.", + "disposition_rationale": "On probe success the guard is equal to the shared lookup by construction; on probe failure FAFF-1463 throws first. Severity downgraded from critical.", + "first_round": 4, + "last_round": 4, + "occurrences": [ + { + "round": 4, + "title": "verifyConfigLocation removal drops a wrong-file mismatch check", + "location": "plugin/skills/faff/bin/lib/rule-inventory.ts:555", + "severity": "observation", + "disposition": "refuted" + } + ] + }, + { + "finding_id": "plugin/skills/faff/bin/lib/rule-inventory.ts::isprobefailure matches the git-probe-failed message token", + "title": "isProbeFailure matches the git-probe-failed message token", + "location": "plugin/skills/faff/bin/lib/rule-inventory.ts:66", + "action": "No change: refuted; see refutation.", + "location_present": true, + "action_present": true, + "severity": "observation", + "source": "adversarial", + "model": "openai/z-ai/glm-5.3-flash", + "disposition": "refuted", + "refutation": "The token is the designed contract: MainWorktreeProbeError sets message git-probe-failed and governedExitCode (shared-infra.js) branches on the same message; spec-review-round.js:39 uses the identical check. Tests 40 and 42 pin the end-to-end dispatch line, so a token drift fails them.", + "disposition_rationale": "Repo convention, pinned end to end. Severity downgraded from major.", + "first_round": 4, + "last_round": 4, + "occurrences": [ + { + "round": 4, + "title": "isProbeFailure matches the git-probe-failed message token", + "location": "plugin/skills/faff/bin/lib/rule-inventory.ts:66", + "severity": "observation", + "disposition": "refuted" + } + ] + }, + { + "finding_id": "plugin/skills/faff/bin/lib/rule-inventory.ts::cmdcheck rethrows the probe error to an unseen caller", + "title": "cmdCheck rethrows the probe error to an unseen caller", + "location": "plugin/skills/faff/bin/lib/rule-inventory.ts:733", + "action": "No change: refuted; see refutation.", + "location_present": true, + "action_present": true, + "severity": "observation", + "source": "adversarial", + "model": "openai/z-ai/glm-5.3-flash", + "disposition": "refuted", + "refutation": "cmdCheck is reached only through cmdRuleInventory from the bin/faff dispatch table, which wraps every handler in runGovernedDispatch (bin/faff:365); tests 40 and 42 exercise that path and assert exit 2 and the one line.", + "disposition_rationale": null, + "first_round": 4, + "last_round": 4, + "occurrences": [ + { + "round": 4, + "title": "cmdCheck rethrows the probe error to an unseen caller", + "location": "plugin/skills/faff/bin/lib/rule-inventory.ts:733", + "severity": "observation", + "disposition": "refuted" + } + ] + }, + { + "finding_id": "test/rule-inventory.test.mjs::probe-line assertion couples to git's stderr wording", + "title": "probe-line assertion couples to git's stderr wording", + "location": "test/rule-inventory.test.mjs:736", + "action": "No change: refuted; see refutation.", + "location_present": true, + "action_present": true, + "severity": "observation", + "source": "adversarial", + "model": "openai/z-ai/glm-5.3-flash", + "disposition": "refuted", + "refutation": "'fatal: injected failure' is written by the test's own PATH git wrapper, not by git, so no git version can change it; the dispatch prefix pin is intentional (62220446).", + "disposition_rationale": null, + "first_round": 4, + "last_round": 4, + "occurrences": [ + { + "round": 4, + "title": "probe-line assertion couples to git's stderr wording", + "location": "test/rule-inventory.test.mjs:736", + "severity": "observation", + "disposition": "refuted" + } + ] + }, + { + "finding_id": "plugin/skills/faff/bin/lib/shared-infra.js::main-checkout probe failure is now fatal where its answer is irrelevant", + "title": "main-checkout probe failure is now fatal where its answer is irrelevant", + "location": "plugin/skills/faff/bin/lib/shared-infra.js:594", + "action": "No change: accepted as FAFF-1463 design; no change in this PR.", + "location_present": true, + "action_present": true, + "severity": "observation", + "source": "adversarial", + "model": "openai/z-ai/glm-5.3-flash", + "disposition": "accepted-risk", + "refutation": null, + "disposition_rationale": "That is FAFF-1463's merged design and the spec's fail-closed rule (any git error exits 2); out of this delta's scope.", + "first_round": 4, + "last_round": 4, + "occurrences": [ + { + "round": 4, + "title": "main-checkout probe failure is now fatal where its answer is irrelevant", + "location": "plugin/skills/faff/bin/lib/shared-infra.js:594", + "severity": "observation", + "disposition": "accepted-risk" + } + ] + }, + { + "finding_id": ".faff/runs/run-20261104-043908-graft-FAFF-1428/FAFF-1428/holdout.json::holdout predates the rebased head", + "title": "holdout predates the rebased head", + "location": ".faff/runs/run-20261104-043908-graft-FAFF-1428/FAFF-1428/holdout.json", + "action": "Re-run the code-blind holdout on 7c0e20de before merge.", + "location_present": true, + "action_present": true, + "severity": "observation", + "source": "standard", + "model": "anthropic/claude-opus-5-5", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": null, + "first_round": 4, + "last_round": 4, + "occurrences": [ + { + "round": 4, + "title": "holdout predates the rebased head", + "location": ".faff/runs/run-20261104-043908-graft-FAFF-1428/FAFF-1428/holdout.json", + "severity": "observation", + "disposition": "open" + } + ] + }, + { + "finding_id": "test/rule-inventory.test.mjs::redundant re-fetch double-sources origin/head in the no-base merge-base test", + "title": "Redundant re-fetch double-sources origin/HEAD in the no-base merge-base test", + "location": "test/rule-inventory.test.mjs:442", + "action": "Drop the fetch after remote set-head so set-head is the only source of origin/HEAD on every git version.", + "location_present": true, + "action_present": true, + "severity": "observation", + "source": "standard", + "model": "claude-opus-5-5", + "disposition": "open", + "refutation": null, + "disposition_rationale": null, + "first_round": 5, + "last_round": 5, + "occurrences": [ + { + "round": 5, + "title": "Redundant re-fetch double-sources origin/HEAD in the no-base merge-base test", + "location": "test/rule-inventory.test.mjs:442", + "severity": "observation", + "disposition": "open" + } + ] + }, + { + "finding_id": "test/rule-inventory.test.mjs::update-ref -d on an absent origin/head breaks fixtures on older git", + "title": "update-ref -d on an absent origin/HEAD breaks fixtures on older git", + "location": "test/rule-inventory.test.mjs:114", + "action": "None; deleting a missing ref exits 0.", + "location_present": true, + "action_present": true, + "severity": "major", + "source": "adversarial", + "model": "openai/z-ai/glm-5.3-flash", + "disposition": "refuted", + "refutation": null, + "disposition_rationale": "git update-ref --no-deref -d on a missing ref exits 0 on git 2.39.5 and 2.30.6; the suite passes 45/45 on 2.39.5.", + "first_round": 5, + "last_round": 5, + "occurrences": [ + { + "round": 5, + "title": "update-ref -d on an absent origin/HEAD breaks fixtures on older git", + "location": "test/rule-inventory.test.mjs:114", + "severity": "major", + "disposition": "refuted" + } + ] + }, + { + "finding_id": "test/rule-inventory.test.mjs::collect-then-assert sweep loses strictness", + "title": "Collect-then-assert sweep loses strictness", + "location": "test/rule-inventory.test.mjs:800", + "action": "None; the pass/fail conditions are equivalent.", + "location_present": true, + "action_present": true, + "severity": "minor", + "source": "adversarial", + "model": "openai/z-ai/glm-5.3-flash", + "disposition": "refuted", + "refutation": null, + "disposition_rationale": "Status, stdout and probe stderr are each still checked per call, and non-probe stderr was never asserted. The inline version also aborted before the probes.length pin. Mutants show one failing run reports 4 and 2 mismatches.", + "first_round": 5, + "last_round": 5, + "occurrences": [ + { + "round": 5, + "title": "Collect-then-assert sweep loses strictness", + "location": "test/rule-inventory.test.mjs:800", + "severity": "minor", + "disposition": "refuted" + } + ] + } + ] +} diff --git a/.faff/anchors/run-20261104-043908-graft-FAFF-1428/FAFF-1428/run-ledger.json b/.faff/anchors/run-20261104-043908-graft-FAFF-1428/FAFF-1428/run-ledger.json new file mode 100644 index 000000000..03c567d67 --- /dev/null +++ b/.faff/anchors/run-20261104-043908-graft-FAFF-1428/FAFF-1428/run-ledger.json @@ -0,0 +1,31 @@ +{ + "schema_version": 1, + "run_id": "run-20261104-043908-graft-FAFF-1428", + "level": "L2", + "admitted": [ + "FAFF-1428" + ], + "outcomes": {}, + "budget": { + "envelope": { + "ceilings": {}, + "at_ceiling": "stop" + } + }, + "base_sha": "3553d023d83486d59951116d0c2747bea0c9aed8", + "base_sha_required": true, + "owner": { + "status": "running", + "session_id": "run-20261104-043908-graft-FAFF-1428", + "pid": 3652542, + "started_at": "2026-11-04T04:39:08.264Z", + "utc_offset_min": 0, + "last_heartbeat": "2026-11-04T04:39:08.264Z", + "faff_version": "0.44.0" + }, + "capabilities": { + "FAFF-1428": { + "sut_env_stood": true + } + } +} diff --git a/.faff/anchors/run-20261104-043908-graft-FAFF-1428/FAFF-1428/spec-review-verdict.json b/.faff/anchors/run-20261104-043908-graft-FAFF-1428/FAFF-1428/spec-review-verdict.json new file mode 100644 index 000000000..8973762a4 --- /dev/null +++ b/.faff/anchors/run-20261104-043908-graft-FAFF-1428/FAFF-1428/spec-review-verdict.json @@ -0,0 +1,429 @@ +{ + "schema_version": 1, + "faff_version": "0.44.0", + "verdict": "reject-approach", + "final_verdict": "reject-approach", + "rounds_total": 5, + "objections": [ + { + "lens": "architectural", + "severity": "minor", + "claim": "The `Normalise` definition strips only whitespace (\"Nothing else is stripped; markdown emphasis and backticks inside the text are kept\"). The dominant prose form in the watched files is the house bold-lead mega-bullet (`- **Lean.** Say it once…`, per `docs/reference/skill-authoring.md`). A deleted bullet's normalised deleted text therefore contains literal `**Lean.**` and any inline-code backticks; an author who quotes the rule's words (\"Lean. Say it once…\") — the natural quote — is *not* a substring and the block is uncovered. The spec anticipates backtick-run escaping for anchors *containing* backticks but never says emphasis markers must be reproduced, and the failure message points at the format doc, which won't say it either. Every extraction PR deleting bold-lead bullets hits this systematically until authors discover it by trial.", + "evidence": "`Normalise` row in the vocabulary table; the anchor-matching rule in \"How a deleted block maps to a row\"; house style in `docs/reference/skill-authoring.md`.", + "predicted_consequence": "the first extraction PR under this check produces a burst of false uncovered-block failures whose fix (embedding `**` inside code spans) is undiscoverable from the failure message; the spec edit is one line — strip `*`/`_` emphasis and backtick delimiters in `Normalise`, or state the reproduction requirement in the format doc.", + "spec_anchor": "3-what-vocabulary-types-and-interfaces", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": null, + "first_round": 1, + "last_round": 1, + "occurrences": [ + { + "round": 1, + "severity": "minor", + "claim": "The `Normalise` definition strips only whitespace (\"Nothing else is stripped; markdown emphasis and backticks inside the text are kept\"). The dominant prose form in the watched files is the house bold-lead mega-bullet (`- **Lean.** Say it once…`, per `docs/reference/skill-authoring.md`). A deleted bullet's normalised deleted text therefore contains literal `**Lean.**` and any inline-code backticks; an author who quotes the rule's words (\"Lean. Say it once…\") — the natural quote — is *not* a substring and the block is uncovered. The spec anticipates backtick-run escaping for anchors *containing* backticks but never says emphasis markers must be reproduced, and the failure message points at the format doc, which won't say it either. Every extraction PR deleting bold-lead bullets hits this systematically until authors discover it by trial.", + "spec_anchor": "3-what-vocabulary-types-and-interfaces" + } + ] + }, + { + "lens": "architectural", + "severity": "minor", + "claim": "`added_corpus := normalise(join of every added line of every watched file, with single spaces)` concatenates all added lines of all files into one stream. A deleted block's text can then match across the seam between two unrelated added lines (even in different files), counting as \"moved\" when nothing was moved. This is a fail-open hole in a check whose stated principle is \"Fail closed inside scope … It never turns into a pass.\" The fix costs nothing: build the corpus per watched file (or per added block) and test membership against each.", + "evidence": "PROCEDURE step 6 and step 9's substring test; the \"Fail closed inside scope\" design principle in section 1.", + "predicted_consequence": "a rare but real false pass on exactly the class of deletion the check exists to catch, contradicting the spec's own fail-closed commitment; reviewers cannot distinguish a genuine move from a seam artifact.", + "spec_anchor": "3-what-vocabulary-types-and-interfaces", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": null, + "first_round": 1, + "last_round": 1, + "occurrences": [ + { + "round": 1, + "severity": "minor", + "claim": "`added_corpus := normalise(join of every added line of every watched file, with single spaces)` concatenates all added lines of all files into one stream. A deleted block's text can then match across the seam between two unrelated added lines (even in different files), counting as \"moved\" when nothing was moved. This is a fail-open hole in a check whose stated principle is \"Fail closed inside scope … It never turns into a pass.\" The fix costs nothing: build the corpus per watched file (or per added block) and test membership against each.", + "spec_anchor": "3-what-vocabulary-types-and-interfaces" + } + ] + }, + { + "lens": "infosec", + "severity": "major", + "claim": "The check grants \"pass\" on the strength of the `## Rule inventory` rows in the committed spec, but the committed spec is written by the same agent whose PR it gates. An agent (or any PR author) that deletes a rule can satisfy the check by writing its own row — `dropped: `, or a `kept` row naming a real test that does not test the rule — quoting ≥20 characters of the deleted text. The check verifies only that the quote is a substring of the deleted prose and that named files exist; it cannot distinguish a truthful row from a fabricated one. Worse, rows are pooled across all committed specs with no file binding: a row in an unrelated old spec under the spec docs path covers deletions in any watched file, and one anchor matching duplicated text covers many blocks at once. The design principle \"Coverage is mechanical; correctness stays with review\" is the stated control, but it does not answer the threat: the reviewer sees a plausible-looking table and the mechanical gate adds zero adversarial resistance — it enforces the format, not the coverage, against a non-cooperative actor. The spec must establish who can write the input that grants the pass; it establishes only that the actor is the PR author.", + "evidence": "§3 \"Coverage is mechanical; correctness stays with review\"; §3 \"One row may cover several blocks\"; §4 step 7 \"rows := for each spec … parse every inventory table\" (pooled, unbound to the deleted file); §4 step 9 (anchor-substring is the only coverage test).", + "predicted_consequence": "an agent PR can delete any rule from any watched skill file and pass `faff rule-inventory check` (exit 0) by authoring a `dropped:` row in any committed spec — the guardrail the ticket exists to add is trivially bypassable by the actor it targets, and the CI log shows a green check.", + "spec_anchor": "how-a-deleted-block-maps-to-a-row", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": null, + "first_round": 1, + "last_round": 1, + "occurrences": [ + { + "round": 1, + "severity": "major", + "claim": "The check grants \"pass\" on the strength of the `## Rule inventory` rows in the committed spec, but the committed spec is written by the same agent whose PR it gates. An agent (or any PR author) that deletes a rule can satisfy the check by writing its own row — `dropped: `, or a `kept` row naming a real test that does not test the rule — quoting ≥20 characters of the deleted text. The check verifies only that the quote is a substring of the deleted prose and that named files exist; it cannot distinguish a truthful row from a fabricated one. Worse, rows are pooled across all committed specs with no file binding: a row in an unrelated old spec under the spec docs path covers deletions in any watched file, and one anchor matching duplicated text covers many blocks at once. The design principle \"Coverage is mechanical; correctness stays with review\" is the stated control, but it does not answer the threat: the reviewer sees a plausible-looking table and the mechanical gate adds zero adversarial resistance — it enforces the format, not the coverage, against a non-cooperative actor. The spec must establish who can write the input that grants the pass; it establishes only that the actor is the PR author.", + "spec_anchor": "how-a-deleted-block-maps-to-a-row" + } + ] + }, + { + "lens": "infosec", + "severity": "major", + "claim": "The check applies only when the PR commits a spec under the spec docs path, and a PR that does not returns `skipped` with exit 0 (§3 exit table: \"exit 0 pass or skipped\"). In branch protection, exit 0 is a green required check — a skip is indistinguishable from a pass. So an agent PR that deletes skill prose and commits no spec sails through the gate. The spec's answer is that graft's `faff spec commit` always commits the spec, and a violation \"is a graft defect to file, not a reason to change this scope\" — but nothing in this check or in CI enforces that invariant; the gate's coverage rests entirely on an unverified precondition controlled by the gated actor. The precedent cited (`governance.yml` `on-missing: pass`) is a governance floor with a documented residual and a named flip-to-fail path; this spec states no such end-state and no detection beyond a human reading CI logs for `skipped_reason: \"no-spec-committed\"` on a PR that carries an anchor footprint.", + "evidence": "§3 \"Chosen: scope by artifact footprint\"; §4 failure modes third bullet (\"it is a graft defect to file, not a reason to change this scope\"); §3 exit table (skipped = exit 0).", + "predicted_consequence": "a prose-deleting agent PR with no committed spec passes the required `validate` check with exit 0; the rule loss the ticket was created to catch (FAFF-1429/FAFF-1430-shaped) ships green.", + "spec_anchor": "pure-prose-edits", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": null, + "first_round": 1, + "last_round": 1, + "occurrences": [ + { + "round": 1, + "severity": "major", + "claim": "The check applies only when the PR commits a spec under the spec docs path, and a PR that does not returns `skipped` with exit 0 (§3 exit table: \"exit 0 pass or skipped\"). In branch protection, exit 0 is a green required check — a skip is indistinguishable from a pass. So an agent PR that deletes skill prose and commits no spec sails through the gate. The spec's answer is that graft's `faff spec commit` always commits the spec, and a violation \"is a graft defect to file, not a reason to change this scope\" — but nothing in this check or in CI enforces that invariant; the gate's coverage rests entirely on an unverified precondition controlled by the gated actor. The precedent cited (`governance.yml` `on-missing: pass`) is a governance floor with a documented residual and a named flip-to-fail path; this spec states no such end-state and no detection beyond a human reading CI logs for `skipped_reason: \"no-spec-committed\"` on a PR that carries an anchor footprint.", + "spec_anchor": "pure-prose-edits" + } + ] + }, + { + "lens": "infosec", + "severity": "minor", + "claim": "The committed spec is agent-authored untrusted input, and the command prints spec-derived strings raw: row-error reasons include spec content (the anchor, the reason text, the test name), and `--json`/plain output carries them to the step log. A spec line containing GitHub Actions workflow-command syntax (`::error::`, `::warning::`) or ANSI escapes can forge annotations or conceal real findings in the `validate` job log. The spec sanitises nothing and imposes no character restrictions on the `reason` free text or the anchor.", + "evidence": "§3 \"Without `--json` it prints one line per uncovered block and per row error\"; §4 step 8 row errors interpolate spec content; no escaping rule anywhere in §4.", + "predicted_consequence": "a crafted `dropped: ::error::…` reason forges a CI annotation or visually suppresses the real row-error output in the validate job log.", + "spec_anchor": "command-surface", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": null, + "first_round": 1, + "last_round": 1, + "occurrences": [ + { + "round": 1, + "severity": "minor", + "claim": "The committed spec is agent-authored untrusted input, and the command prints spec-derived strings raw: row-error reasons include spec content (the anchor, the reason text, the test name), and `--json`/plain output carries them to the step log. A spec line containing GitHub Actions workflow-command syntax (`::error::`, `::warning::`) or ANSI escapes can forge annotations or conceal real findings in the `validate` job log. The spec sanitises nothing and imposes no character restrictions on the `reason` free text or the anchor.", + "spec_anchor": "command-surface" + } + ] + }, + { + "lens": "infosec", + "severity": "minor", + "claim": "A row's covering-test entry must \"start `test/` and end `.test.mjs`\", but `test/../../.test.mjs` satisfies both. The check then runs `git cat-file -e HEAD:` and, when a name is given, reads the file's text to assert containment. A crafted row can make the check probe existence of, and string-containment in, any tracked file at HEAD — a small oracle and an inconsistent-with-intent read surface, and the containment assertion against a non-test file is meaningless.", + "evidence": "§3 TestRef \"file: Path — repo-relative, must start `test/` and end `.test.mjs`\"; §4 step 8 (`git cat-file -e HEAD:`, \"the file text must contain the name as written\").", + "predicted_consequence": "a row naming `test/../../records/…​.test.mjs` passes the shape check and turns the gate into an existence/containment oracle over arbitrary tracked paths.", + "spec_anchor": "the-inventory-format-documented-in-docs-references-skill-authoring-md", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": null, + "first_round": 1, + "last_round": 1, + "occurrences": [ + { + "round": 1, + "severity": "minor", + "claim": "A row's covering-test entry must \"start `test/` and end `.test.mjs`\", but `test/../../.test.mjs` satisfies both. The check then runs `git cat-file -e HEAD:` and, when a name is given, reads the file's text to assert containment. A crafted row can make the check probe existence of, and string-containment in, any tracked file at HEAD — a small oracle and an inconsistent-with-intent read surface, and the containment assertion against a non-test file is meaningless.", + "spec_anchor": "the-inventory-format-documented-in-docs-references-skill-authoring-md" + } + ] + }, + { + "lens": "QA", + "severity": "minor", + "claim": "\"Exempt block\" is defined as a *block* that states no rule, but two of its criteria are line-level (\"a blank line\", \"a line with no letters\") while others are block-level (\"a heading\", \"a table separator row\", \"the header row\"). For a deleted block containing both a letterless line and a lettered line, the spec does not say whether the block is exempt, partially exempt, or a deleted block whose deleted text includes the letterless line. A builder can implement either reading and both satisfy the text; a tester cannot decide which is correct.", + "evidence": "§3 Vocabulary, \"Exempt block\"; DONE item \"Headings, blank lines, table separator rows, table header rows and letterless lines are exempt\" (which repeats the same block/line conflation).", + "predicted_consequence": "two conforming builds disagree on whether a mixed block is uncovered, flipping exit 1 vs exit 0 on the same diff; the DONE item cannot be decided for that input.", + "spec_anchor": "3-what-vocabulary-types-and-interfaces", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": null, + "first_round": 1, + "last_round": 1, + "occurrences": [ + { + "round": 1, + "severity": "minor", + "claim": "\"Exempt block\" is defined as a *block* that states no rule, but two of its criteria are line-level (\"a blank line\", \"a line with no letters\") while others are block-level (\"a heading\", \"a table separator row\", \"the header row\"). For a deleted block containing both a letterless line and a lettered line, the spec does not say whether the block is exempt, partially exempt, or a deleted block whose deleted text includes the letterless line. A builder can implement either reading and both satisfy the text; a tester cannot decide which is correct.", + "spec_anchor": "3-what-vocabulary-types-and-interfaces" + } + ] + }, + { + "lens": "architectural", + "severity": "minor", + "claim": "The spec requires both that step 5 \"print a `::notice::` line\" and that \"Every printed line starts with the fixed prefix `rule-inventory: `\". GitHub's runner only interprets a workflow command at the start of a line, so under the prefix rule the notice is inert literal text; dropping the prefix for that one line violates the hygiene rule exactly as written (and the rule's stated purpose — no line starting with `::` — is what scenario 10 pins for `::error::`). The spec as written cannot satisfy both; the builder must silently pick one.", + "evidence": "§4 step 5 (\"print a ::notice:: line and return skipped\") vs §3 \"Output hygiene\" (\"Every printed line starts with the fixed prefix `rule-inventory: `\"); scenario \"no line of its output starts with `::error::`\".", + "predicted_consequence": "Two PRs implementing the same spec emit differently formatted skip notices — one an inert text line, one a working workflow command — and a later hygiene audit flags whichever choice contradicts the letter of the rule.", + "spec_anchor": "what-how", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": null, + "first_round": 2, + "last_round": 2, + "occurrences": [ + { + "round": 2, + "severity": "minor", + "claim": "The spec requires both that step 5 \"print a `::notice::` line\" and that \"Every printed line starts with the fixed prefix `rule-inventory: `\". GitHub's runner only interprets a workflow command at the start of a line, so under the prefix rule the notice is inert literal text; dropping the prefix for that one line violates the hygiene rule exactly as written (and the rule's stated purpose — no line starting with `::` — is what scenario 10 pins for `::error::`). The spec as written cannot satisfy both; the builder must silently pick one.", + "spec_anchor": "what-how" + } + ] + }, + { + "lens": "infosec", + "severity": "minor", + "claim": "The exemption set is decided per block with no content test for headings and header rows — a deleted block is skipped from coverage *because of its markdown shape*, not because it states no rule. A rule carried as a heading (`### Never invoke the prd slot without a run dir`) or as a table header cell is deleted with no row, no move requirement, and no failure: the check returns `skipped(\"no-prose-deleted\")` if that was the only deletion, or omits the block from `uncovered` otherwise. This is a fail-open path the spec itself creates inside its own scope, not a careless-build risk — the exemption list is normative in §3.", + "evidence": "§3 \"Exempt block … a heading, a table separator row (`|---|`), the header row of a table … or a block whose deleted text has no letters\"; §4 step 3 \"drop exempt blocks\"; §8 DONE \"Headings, table separator rows, table header rows and blocks whose deleted text has no letters are exempt, decided per block.\"", + "predicted_consequence": "an agent PR deletes a rule-bearing heading, the check exits 0, and the rule leaves the runtime prompt with no inventory row and no reviewer-facing `dropped` entry in the step summary.", + "spec_anchor": "what-vocabulary-types-and-interfaces", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": null, + "first_round": 2, + "last_round": 2, + "occurrences": [ + { + "round": 2, + "severity": "minor", + "claim": "The exemption set is decided per block with no content test for headings and header rows — a deleted block is skipped from coverage *because of its markdown shape*, not because it states no rule. A rule carried as a heading (`### Never invoke the prd slot without a run dir`) or as a table header cell is deleted with no row, no move requirement, and no failure: the check returns `skipped(\"no-prose-deleted\")` if that was the only deletion, or omits the block from `uncovered` otherwise. This is a fail-open path the spec itself creates inside its own scope, not a careless-build risk — the exemption list is normative in §3.", + "spec_anchor": "what-vocabulary-types-and-interfaces" + } + ] + }, + { + "lens": "QA", + "severity": "minor", + "claim": "The scenario cannot be executed as written — the `When` clause omits the `--json` flag whose output the `Then` clause asserts, so a tester cannot decide what command to run or whether a failing build mis-routed the flag.", + "evidence": "Scenario \"Given a branch that deletes two bullets from plugin/skills/faff-prep/SKILL.md … When `faff rule-inventory check --base origin/main` runs / Then it exits 0 / And `--json` reports covered_by_row 2 and uncovered []\".", + "predicted_consequence": "The fixture test for the check's happy covered-by-row path is ambiguous: a builder may either add `--json` to the command or read the human-readable summary, and the two are not equivalent outputs — the assertion \"covered_by_row 2\" is only decidable on the JSON object.", + "spec_anchor": "5-scenarios", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": null, + "first_round": 2, + "last_round": 2, + "occurrences": [ + { + "round": 2, + "severity": "minor", + "claim": "The scenario cannot be executed as written — the `When` clause omits the `--json` flag whose output the `Then` clause asserts, so a tester cannot decide what command to run or whether a failing build mis-routed the flag.", + "spec_anchor": "5-scenarios" + } + ] + }, + { + "lens": "architectural", + "severity": "minor", + "claim": "The spec justifies blocking from day one by claiming nine shipped inventories prove the format and \"the only new requirement is the code-span anchor.\" That is not true. Section 3 also introduces (a) a strict disposition grammar — exactly `kept`, `changed: `, or `dropped: ` — and (b) a mandatory covering test for every `kept` row. The spec's own cited precedent, FAFF-1418's `## Rule inventory` (supplied in context), has no `Covering test` column at all and ~25 `kept` rows, most of them \"kept in prose\" with no test and no test possible. Under the new parser, every one of those rows is a row error, and the format has no natural disposition for a rule kept in prose: `kept` demands a test, so the author must misfile it as `changed: kept in prose`, which reads as a semantic lie in the summary table the reviewer is told to trust.", + "evidence": "Design decision \"**Blocking from day one, or report first.**\" (\"the only new requirement is the code-span anchor\") vs section 3's disposition and covering-test rules; FAFF-1418's inventory table in the supplied context.", + "predicted_consequence": "The first real extraction PR under this check cannot reuse the proven inventory style its author just shipped; it must retrofit every kept-in-prose row into a disposition the grammar doesn't actually have, or the check reddens on the format the spec cited as proof of readiness.", + "spec_anchor": "blocking-from-day-one-or-report-first", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": null, + "first_round": 3, + "last_round": 3, + "occurrences": [ + { + "round": 3, + "severity": "minor", + "claim": "The spec justifies blocking from day one by claiming nine shipped inventories prove the format and \"the only new requirement is the code-span anchor.\" That is not true. Section 3 also introduces (a) a strict disposition grammar — exactly `kept`, `changed: `, or `dropped: ` — and (b) a mandatory covering test for every `kept` row. The spec's own cited precedent, FAFF-1418's `## Rule inventory` (supplied in context), has no `Covering test` column at all and ~25 `kept` rows, most of them \"kept in prose\" with no test and no test possible. Under the new parser, every one of those rows is a row error, and the format has no natural disposition for a rule kept in prose: `kept` demands a test, so the author must misfile it as `changed: kept in prose`, which reads as a semantic lie in the summary table the reviewer is told to trust.", + "spec_anchor": "blocking-from-day-one-or-report-first" + } + ] + }, + { + "lens": "infosec", + "severity": "minor", + "claim": "The summary writer strips control characters and caps length, but does not escape markdown metacharacters. A committed spec (untrusted input, written by the gated agent) can carry a `dropped:` reason or anchor containing `|`, backticks, or `![label](https://attacker/px)`; those bytes flow verbatim into the markdown table appended to `$GITHUB_STEP_SUMMARY`, which GitHub renders on the PR's check page. A `|` breaks the table (hiding the dropped-row surfacing the check exists to provide); an image/link renders attacker-chosen content on a GitHub-trusted surface.", + "evidence": "§4 \"Output hygiene\" — \"Every spec-derived value is stripped of control characters … and capped at 200 characters before it is … written to the summary\"; the CI step passes `$GITHUB_STEP_SUMMARY`. No escaping of `|`, `` ` ``, `[]()`, `!` is specified anywhere.", + "predicted_consequence": "A spec author controls rendered markdown on the PR check page (external-image beacon, link injection, or a broken table that suppresses the dropped/changed rows from the reviewer's view).", + "spec_anchor": "output-hygiene-the-committed-spec-is-untrusted-input", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": null, + "first_round": 3, + "last_round": 3, + "occurrences": [ + { + "round": 3, + "severity": "minor", + "claim": "The summary writer strips control characters and caps length, but does not escape markdown metacharacters. A committed spec (untrusted input, written by the gated agent) can carry a `dropped:` reason or anchor containing `|`, backticks, or `![label](https://attacker/px)`; those bytes flow verbatim into the markdown table appended to `$GITHUB_STEP_SUMMARY`, which GitHub renders on the PR's check page. A `|` breaks the table (hiding the dropped-row surfacing the check exists to provide); an image/link renders attacker-chosen content on a GitHub-trusted surface.", + "spec_anchor": "output-hygiene-the-committed-spec-is-untrusted-input" + } + ] + }, + { + "lens": "QA", + "severity": "minor", + "claim": "`footprint := some path with status A starts \".faff/anchors/\"`. A continuation PR on the same run (rebase, follow-up fix, second extraction against the same issue) modifies the anchor graft already committed — status `M`, not `A`. Such a PR deletes prose, carries the agent footprint, commits no new spec, and the check returns `skipped: no-spec-committed` with a `::notice::` — exactly the silent skip the footprint scope exists to prevent. The spec's own failure mode \"An agent PR commits neither a spec nor an anchor\" names the neither-case but not the modified-anchor case.", + "evidence": "§3 \"Agent footprint\" definition; §4 step 2; failure mode \"An agent PR commits neither a spec nor an anchor.\"", + "predicted_consequence": "an agent PR that deletes skill prose with no inventory row merges green; the check's core guarantee silently does not apply to it, and nothing in the test suite or scenarios would catch it.", + "spec_anchor": "3-what-vocabulary-types-and-interfaces", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": null, + "first_round": 3, + "last_round": 3, + "occurrences": [ + { + "round": 3, + "severity": "minor", + "claim": "`footprint := some path with status A starts \".faff/anchors/\"`. A continuation PR on the same run (rebase, follow-up fix, second extraction against the same issue) modifies the anchor graft already committed — status `M`, not `A`. Such a PR deletes prose, carries the agent footprint, commits no new spec, and the check returns `skipped: no-spec-committed` with a `::notice::` — exactly the silent skip the footprint scope exists to prevent. The spec's own failure mode \"An agent PR commits neither a spec nor an anchor\" names the neither-case but not the modified-anchor case.", + "spec_anchor": "3-what-vocabulary-types-and-interfaces" + } + ] + }, + { + "lens": "architectural", + "severity": "minor", + "claim": "The moved test matches a deleted block's joined text against a **single** added run (\"Runs are never joined to each other\"), but a reflow that leaves two or more consecutive unchanged lines in the middle of a paragraph splits both the deletions and the additions into separate `-U0` hunks. The block's deleted text (joined across the unchanged line) is then a substring of no single added run, so the block is not moved — directly contradicting the spec's own edge-case bullet, which asserts reflows are always moved.", + "evidence": "§3 \"Moved\" definition and the \"Runs are never joined to each other\" clause; §4 edge case \"**A paragraph reflowed.** The deleted lines join to the same normalised text the added lines join to, so it is moved.\"", + "predicted_consequence": "A pure word-preserving reflow that happens to preserve an interior line fails as \"uncovered\" and demands a `changed: reworded`-style row for a change that needs none — exactly the noise failure mode §4 names, produced by the spec's own stated guarantee rather than by a careless builder. A builder implementing to the edge-case bullet (e.g., asserting moved-ness in the mutation test) ships a check whose behaviour disagrees with its documented contract.", + "spec_anchor": "4-how-behaviour", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": null, + "first_round": 4, + "last_round": 4, + "occurrences": [ + { + "round": 4, + "severity": "minor", + "claim": "The moved test matches a deleted block's joined text against a **single** added run (\"Runs are never joined to each other\"), but a reflow that leaves two or more consecutive unchanged lines in the middle of a paragraph splits both the deletions and the additions into separate `-U0` hunks. The block's deleted text (joined across the unchanged line) is then a substring of no single added run, so the block is not moved — directly contradicting the spec's own edge-case bullet, which asserts reflows are always moved.", + "spec_anchor": "4-how-behaviour" + } + ] + }, + { + "lens": "architectural", + "severity": "minor", + "claim": "The watched-path list (`plugin/skills/*/SKILL.md` + `plugin/skills/faff/references/*.md`) is a new, independently maintained path list that is **narrower** than the prose set `prose-size.ts`/`prose-baseline.json` already govern (per `docs/reference/skill-authoring.md`: every `SKILL.md` and every `references/*.md`). A rule moved from a watched `SKILL.md` into another skill's `references/*.md` — real skill prose by the charter's definition — is \"not moved\" here and demands a row, while the byte ratchet treats the same file as first-class prose. The two gates now disagree about what counts as skill prose, and the spec names no cross-check.", + "evidence": "§3 \"Watched file\" term; §2 OUT OF SCOPE \"Prose outside `plugin/skills/**/SKILL.md` and `plugin/skills/faff/references/*.md`\" with extension point \"the watched-path list in the module\"; reference-context row for `prose-size.ts` (\"defines which files count as skill prose\").", + "predicted_consequence": "The next prose surface (e.g., a second skill gains `references/`) is added to `prose-baseline.json` but silently escapes rule-inventory — deletions from it need no rows — and the divergence is invisible until a reviewer notices a rule that the byte ratchet guards but the inventory check does not.", + "spec_anchor": "3-what-vocabulary-types-and-interfaces", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": null, + "first_round": 4, + "last_round": 4, + "occurrences": [ + { + "round": 4, + "severity": "minor", + "claim": "The watched-path list (`plugin/skills/*/SKILL.md` + `plugin/skills/faff/references/*.md`) is a new, independently maintained path list that is **narrower** than the prose set `prose-size.ts`/`prose-baseline.json` already govern (per `docs/reference/skill-authoring.md`: every `SKILL.md` and every `references/*.md`). A rule moved from a watched `SKILL.md` into another skill's `references/*.md` — real skill prose by the charter's definition — is \"not moved\" here and demands a row, while the byte ratchet treats the same file as first-class prose. The two gates now disagree about what counts as skill prose, and the spec names no cross-check.", + "spec_anchor": "3-what-vocabulary-types-and-interfaces" + } + ] + }, + { + "lens": "infosec", + "severity": "minor", + "claim": "The spec pins the diff commands without zero-termination or quoted-path handling. `git diff --no-renames --name-status base HEAD` (and `git diff -U0` for hunk headers, and `git show base:`) quote paths containing spaces, quotes, or non-ASCII characters (`core.quotePath`), emitting `\"plugin/skills/my skill/SKILL.md\"` with C-style escapes. A PR that adds or edits a watched file whose directory name contains a space, a quote, or a non-ASCII character produces a quoted path the parser may fail to match against the watched globs — so deletions in that file are never seen, and the check returns `skipped: no-prose-deleted` or a pass while skill prose was deleted. Because the PR author controls the directory name, this is an input-driven fail-open, not a careless-build risk: the exact commands are specified, and nothing in the spec says to use `-z` or to unquote.", + "evidence": "§4 PROCEDURE step 2 (\"git diff --no-renames --name-status base HEAD\") and step 3 (\"git show base:\", \"git diff -U0 … hunk headers\"); no `-z` flag or quoted-path rule appears anywhere in §3/§4.", + "predicted_consequence": "a PR deleting rules from `plugin/skills//SKILL.md` exits 0 (skipped/pass) with the deletion unenforced.", + "spec_anchor": "how-behaviour", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": null, + "first_round": 4, + "last_round": 4, + "occurrences": [ + { + "round": 4, + "severity": "minor", + "claim": "The spec pins the diff commands without zero-termination or quoted-path handling. `git diff --no-renames --name-status base HEAD` (and `git diff -U0` for hunk headers, and `git show base:`) quote paths containing spaces, quotes, or non-ASCII characters (`core.quotePath`), emitting `\"plugin/skills/my skill/SKILL.md\"` with C-style escapes. A PR that adds or edits a watched file whose directory name contains a space, a quote, or a non-ASCII character produces a quoted path the parser may fail to match against the watched globs — so deletions in that file are never seen, and the check returns `skipped: no-prose-deleted` or a pass while skill prose was deleted. Because the PR author controls the directory name, this is an input-driven fail-open, not a careless-build risk: the exact commands are specified, and nothing in the spec says to use `-z` or to unquote.", + "spec_anchor": "how-behaviour" + } + ] + }, + { + "lens": "QA", + "severity": "minor", + "claim": "The scenario cannot pass as specified, because its own fixture triggers a row error the spec elsewhere mandates.", + "evidence": "Scenario \"Given a branch that deletes two bullets and commits a spec with a 'kept' row for one and a 'dropped: superseded by faff x' row for the other … `--summary out.md` … Then it exits 0\". The scenario never states that the `kept` row names a covering test — but §4.1 step 8 and the DONE item make \"kept with no tests\" a row error, which forces exit 1. As written, the fixture's expected outcome contradicts the spec's own validation rule; a builder implementing faithfully fails this scenario, and a builder making it pass must silently add an unstated fixture detail.", + "predicted_consequence": "The scenario is unbuildable as stated — either the test fails spuriously or the fixture is amended off-spec, so this scenario verifies nothing.", + "spec_anchor": "5-scenarios", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": null, + "first_round": 4, + "last_round": 4, + "occurrences": [ + { + "round": 4, + "severity": "minor", + "claim": "The scenario cannot pass as specified, because its own fixture triggers a row error the spec elsewhere mandates.", + "spec_anchor": "5-scenarios" + } + ] + }, + { + "lens": "QA", + "severity": "minor", + "claim": "§3 says only \"Without `--json` it prints one line per uncovered block and per row error, then a one-line summary\" — it never states what those lines contain. The holdout scenario \"…reads 'dropped:' with nothing after the colon … Then it exits 1 And the output names the row's line in the spec\" asserts a property (`names the row's line`) the spec never requires the printed line to carry. `row_errors` entries carry `line` in the JSON record, but nothing says the human-readable rendering includes it.", + "evidence": "Scenario in §5 (the empty-`dropped:` holdout); §3 \"Command surface\" output description.", + "predicted_consequence": "The holdout's `Then` cannot be decided from the spec — a build whose row-error lines omit the spec line number satisfies every stated output requirement yet fails the scenario, and there is no way to say which side is wrong.", + "spec_anchor": "3-what-vocabulary-types-and-interfaces", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": null, + "first_round": 4, + "last_round": 4, + "occurrences": [ + { + "round": 4, + "severity": "minor", + "claim": "§3 says only \"Without `--json` it prints one line per uncovered block and per row error, then a one-line summary\" — it never states what those lines contain. The holdout scenario \"…reads 'dropped:' with nothing after the colon … Then it exits 1 And the output names the row's line in the spec\" asserts a property (`names the row's line`) the spec never requires the printed line to carry. `row_errors` entries carry `line` in the JSON record, but nothing says the human-readable rendering includes it.", + "spec_anchor": "3-what-vocabulary-types-and-interfaces" + } + ] + }, + { + "lens": "architectural", + "severity": "minor", + "claim": "The anchor floor and the coverage rule are inconsistent. Row validation admits any anchor shorter than 20 characters provided it equals the whole normalised deleted text of *some* deleted block; the coverage rule then lets any valid anchor cover any block it is a substring of, with no length or equality restriction. So a short anchor that is legitimately valid for one small block (a short heading, a one-line bullet) silently covers every other deleted block containing that phrase — the spurious coverage the floor was designed to prevent.", + "evidence": "§3 Design decisions, \"How a deleted block maps to a row\": \"The 20-character floor stops an anchor like `` `faff next` `` from covering every block that mentions the command. A deleted block shorter than 20 characters is covered by an anchor equal to its whole normalised deleted text.\" Step 8 validates the floor *or* whole-text equality; step 9 applies only \"anchor is a substring of normalise(b.deleted_text)\".", + "predicted_consequence": "A deleted block passes as `covered_by_row` on the strength of a row written for an unrelated short block; the gate reports pass where its own floor rationale says it should report uncovered. Fix is a one-line spec edit: a sub-20-character anchor covers only the block it exactly equals.", + "spec_anchor": "3-what-vocabulary-types-and-interfaces", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": "Fixed in place after round 5; not re-reviewed. Accepted by the operator after round 5.", + "first_round": 5, + "last_round": 5, + "occurrences": [ + { + "round": 5, + "severity": "minor", + "claim": "The anchor floor and the coverage rule are inconsistent. Row validation admits any anchor shorter than 20 characters provided it equals the whole normalised deleted text of *some* deleted block; the coverage rule then lets any valid anchor cover any block it is a substring of, with no length or equality restriction. So a short anchor that is legitimately valid for one small block (a short heading, a one-line bullet) silently covers every other deleted block containing that phrase — the spurious coverage the floor was designed to prevent.", + "spec_anchor": "3-what-vocabulary-types-and-interfaces" + } + ] + }, + { + "lens": "QA", + "severity": "minor", + "claim": "The DONE item \"Table separator rows and letterless blocks are exempt; a table header row is exempt only when its whole table is deleted, and a heading only when its whole section is deleted\" is required behaviour, but no scenario in section 5 exercises any exemption path, and the tests DONE item only demands \"each scenario in section 5 and each row-error kind\" — exemption is neither, so a spec-compliant test suite may omit it entirely.", + "evidence": "`### From HOW (behaviour)` exemption DONE item vs. the fifteen fenced scenarios in `## 5. SCENARIOS`, none of which deletes a table separator, a header row, or a heading-with-partially-deleted-section.", + "predicted_consequence": "A build that mis-implements exemption — e.g. treats any deleted heading as exempt, or exempts a header row when only some rows of its table are deleted — passes every scenario and every row-error test while letting a rule carried in a heading or header escape the check unquoted. That is precisely the false-negative the check exists to prevent, and nothing in the spec's own acceptance surface would catch it.", + "spec_anchor": "from-how-behaviour", + "disposition": "fixed", + "refutation": null, + "disposition_rationale": "Fixed in place after round 5; not re-reviewed. Accepted by the operator after round 5.", + "first_round": 5, + "last_round": 5, + "occurrences": [ + { + "round": 5, + "severity": "minor", + "claim": "The DONE item \"Table separator rows and letterless blocks are exempt; a table header row is exempt only when its whole table is deleted, and a heading only when its whole section is deleted\" is required behaviour, but no scenario in section 5 exercises any exemption path, and the tests DONE item only demands \"each scenario in section 5 and each row-error kind\" — exemption is neither, so a spec-compliant test suite may omit it entirely.", + "spec_anchor": "from-how-behaviour" + } + ] + } + ] +} diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index d074c41b8..9fc55131c 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -152,6 +152,45 @@ jobs: ;; esac + # FAFF-1428: every block of skill prose a PR deletes must be moved verbatim into other skill prose + # or quoted by a `## Rule inventory` row in a spec the PR adds. Same env-only base resolution as + # the region step above; the no-base command line stays literal for `faff gates discover`. + - name: Rule inventory covers deleted skill prose (FAFF-1428) + env: + EVENT: ${{ github.event_name }} + BASE_REF: ${{ github.base_ref }} + BEFORE: ${{ github.event.before }} + run: | + set -euo pipefail + quote() { node -e 'process.stdout.write(JSON.stringify(process.argv[1]))' -- "$1"; } + case "$EVENT" in + pull_request) + if [[ ! "$BASE_REF" =~ ^[A-Za-z0-9._/-]+$ || "$BASE_REF" == -* ]]; then + echo "::error::pull_request base_ref $(quote "$BASE_REF") is not a plain ref" + exit 1 + fi + git fetch --depth=1 origin "+refs/heads/$BASE_REF:refs/remotes/origin/$BASE_REF" + node plugin/skills/faff/bin/faff rule-inventory check --base "origin/$BASE_REF" --summary "$GITHUB_STEP_SUMMARY" + ;; + push) + if [[ "$BEFORE" =~ ^0{40}$ ]]; then + node plugin/skills/faff/bin/faff rule-inventory check + elif [[ "$BEFORE" =~ ^[0-9a-f]{40}$ ]]; then + if ! git fetch --depth=1 origin "$BEFORE"; then + echo "::error::cannot fetch the push's before commit $BEFORE (unreachable, likely after a force-push); deleted skill prose cannot be checked" + exit 1 + fi + node plugin/skills/faff/bin/faff rule-inventory check --base "$BEFORE" --summary "$GITHUB_STEP_SUMMARY" + else + echo "::error::push event before $(quote "$BEFORE") is neither a 40-hex commit nor all zeros" + exit 1 + fi + ;; + *) + node plugin/skills/faff/bin/faff rule-inventory check + ;; + esac + - name: Registry selftest battery — factory members (incl. the destructive worktree-prune, FAFF-581) run: node plugin/skills/faff/bin/faff regions selftest --region factory diff --git a/docs/guide/cli.md b/docs/guide/cli.md index ef3a779a9..546f3b124 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -56,6 +56,7 @@ install: Each entry is absolute or `~`/`~/…`-relative (a bare unquoted `~` parses as YAML null — quote it: `"~"`); a non-absolute, non-`~` entry is skipped with a stderr notice. Unset, or every entry unusable, falls back to the hardcoded pair above — in both `link-skills.sh --global` and `faff doctor`'s default scan set — byte-for-byte identical to before this key existed. Governs `--global` installs and doctor's default scan set only: never repo-local (non-`--global`) mode, `--target`, or `$CLAUDE_PLUGIN_ROOT`. | `validate-adapters [--skills-dir DIR]` | Lint the shipped slot-skills for conformance drift (CI / pre-commit). Includes the per-file byte ratchet over every `SKILL.md` and `references/*.md` against `plugin/skills/faff/prose-baseline.json`: growth prints `FAIL (prose bytes)` with the size, baseline and `+` and exits 1, as does a covered file with no entry or an entry with no file; a shrink prints a non-failing `RATCHET` line. A missing or malformed baseline fails on the bundled tree; with `--skills-dir` a missing one only prints an `INFO` line. | | `prose-size baseline --lower [--skills-dir DIR] [--json]`; `prose-size tally --from REF --to REF [--scope all\|hot] [--group LABEL=T1,T2,...]... [--repo DIR] [--json]` | **Skill prose size in bytes.** `baseline --lower` rewrites `prose-baseline.json`, lowering each entry whose file shrank; it never raises, adds or removes an entry and reports each grown, unlisted or missing file it left alone (exit 2 on a missing or malformed baseline or a write failure). `tally` reads git history only: for each first-parent commit in `REF..REF` it sizes the covered files (`hot` = the beep-boop, graft and prep `SKILL.md`) from `git ls-tree -l` and prints one row per commit that changed the total (ticket = the last parenthesised key in the subject), then each `--group`'s net, `other`, start, end and net; `--json` emits the same as an object. Exit 2 when the repo or a ref cannot be resolved. No `--selftest`; covered by `test/prose-size.test.mjs`. | +| `rule-inventory check [--base REF] [--json] [--summary FILE]` | **Deleted skill prose must map to a rule-inventory row.** Compares REF's tree with HEAD's (pass a merge-base; without `--base`, the merge-base with `origin/HEAD`, else skipped `no-base`). Every deleted block of skill prose (the `prose-size` covered set) must either appear verbatim inside one block of skill prose at HEAD, or be quoted by a row of a `## Rule inventory` table in a spec the diff adds under the spec docs path (format: `docs/reference/skill-authoring.md#rule-inventory`). Skipped when no prose is deleted, or when no spec is committed and no `.faff/anchors/` path changed; a changed anchor with no spec fails. `--summary FILE` appends the row-covered blocks, `dropped` and `changed` first. Every printed line starts `rule-inventory: `. Exit 0 pass or skipped, 1 fail, 2 a bad ref, git failure, unreadable or over-1-MiB spec, or unreadable config. No `--selftest`; covered by `test/rule-inventory.test.mjs`. | | `validate-adapters --configured [--root DIR]` | Pre-flight *your* configured (swapped-in) slot occupants before an unattended run. | ## Eligibility & routing (pure — no tracker/network) diff --git a/docs/reference/skill-authoring.md b/docs/reference/skill-authoring.md index 1fb078468..41404112b 100644 --- a/docs/reference/skill-authoring.md +++ b/docs/reference/skill-authoring.md @@ -34,6 +34,29 @@ Run over every `SKILL.md`. A `FAIL () ✗