diff --git a/PORTING.md b/PORTING.md index 8896780..dfe06e7 100644 --- a/PORTING.md +++ b/PORTING.md @@ -82,7 +82,10 @@ consequence is a Cursor API reaching a generated skill while every other check s fix is to correct the left-hand side against what upstream actually says now, or to record the rule in `omp-port/stale-exempt.tsv` with a reason. That file is keyed on a literal fragment of the rule and never on a line number, because a line number moves the moment anyone edits a rule above it and -an exemption that silently stops applying is worse than no exemption. +an exemption that silently stops applying is worse than no exemption. `stale exemptions` fails a +fragment that matches more than one rule or none, and an ambiguous fragment exempts nothing. A +`run_in_background: true` fragment once matched a swarm Phase B sentence rule as well as the token +rule it was written for, and kept that rule from failing when it went stale. The probe's own exit status is part of the assertion for both. A probe that cannot run reports failure, not a clean result: an empty list from a failed build is not evidence that nothing is diff --git a/omp-port/check-port.sh b/omp-port/check-port.sh index ad1f4ef..1fa59ee 100755 --- a/omp-port/check-port.sh +++ b/omp-port/check-port.sh @@ -37,47 +37,34 @@ allow_count() { awk -F'\t' -v p="$2" '$0 !~ /^#/ && $2 == p' "$1" | wc -l; } # the gate then goes red for a reason nobody can reconstruct. A fragment survives every edit that # does not change the rule it names. STALE_ALLOW="${STALE_EXEMPT:-$PORT_DIR/stale-exempt.tsv}" -exempt() { - [ -f "$STALE_ALLOW" ] || return 1 - # The needle travels through the environment rather than -v, because awk applies escape - # processing to a -v assignment and a rules.sed left-hand side is full of backslashes: it would - # warn about every one of them and match the stripped text instead of the rule. - # - # Three ways a substring match can go wrong are closed here, because each one silently exempts - # more than the author intended and a wrong exemption turns the gate green, which is the exact - # failure this assertion exists to prevent: - # a whitespace-only fragment, which index() matches against almost any rule, so a stray tab - # or a double space in the file would exempt the entire table; - # a fragment too short to be distinctive, which a later rule could match by accident; - # a fragment that is only a prefix of a longer rule, which exempts that rule wholesale. - STALE_RULE="$1" awk -F'\t' ' - BEGIN { rule = ENVIRON["STALE_RULE"] } - $0 !~ /^#/ { - f = $1 - gsub(/^[[:space:]]+|[[:space:]]+$/, "", f) - # The length floor alone is not enough: a run of twenty spaces is twenty characters long - # and index() finds it in almost any rule, so a whitespace-only row exempts the table. - if (length(f) >= 20 && f ~ /[^[:space:]]/ && index(rule, f)) hit = 1 - } - END { exit !hit } - ' "$STALE_ALLOW" -} -# Every fragment has to clear the same bar the matcher enforces, so a typo is caught at review time -# rather than discovered as an exemption that quietly stopped applying. -stale_exempt_bad() { +# Resolves each exemption to the one rule it names, printing `ok` or +# `bad` per entry, so the lint and the staleness check read one answer. Only a fragment +# that names exactly one rule exempts anything. A blank or short fragment is found by index() in +# almost any rule, and a fragment that matches two rules lets the one it was not written for go +# stale in silence: a `run_in_background: true` fragment once hid a swarm Phase B rule that way. +# Only a genuinely empty line is not an entry; a line holding whitespace is one, and fails. +stale_exempt_resolve() { [ -f "$STALE_ALLOW" ] || return 0 awk -F'\t' ' - $0 !~ /^#/ { - # Only a genuinely empty line is not an entry. A line holding whitespace is one, and is - # caught below: that is the shape index() would match against every rule, so skipping it - # here would reinstate the exact hole this lint exists to close. - if ($0 == "") next + FILENAME == ARGV[1] { + if ($0 ~ /^[0-9]+\t/) { n++; at[n] = $1; rule[n] = substr($0, length($1) + 2) } + else rule[n] = rule[n] "\n" $0 + next + } + $0 !~ /^#/ && $0 != "" { f = $1 gsub(/^[[:space:]]+|[[:space:]]+$/, "", f) - if (f !~ /[^[:space:]]/ || length(f) < 20) - printf " rules.sed exemption fragment is blank or under 20 chars: [%s]\n", f + if (f !~ /[^[:space:]]/ || length(f) < 20) { + printf "bad\tstale-exempt.tsv:%d fragment is blank or under 20 chars: [%s]\n", FNR, f + next + } + c = 0; hits = "" + for (i = 1; i <= n; i++) if (index(rule[i], f)) hits = hits (c++ ? ", " : "") at[i] + if (c == 1) printf "ok\t%s\n", hits + else if (c == 0) printf "bad\tstale-exempt.tsv:%d fragment matches no rule: [%s]\n", FNR, f + else printf "bad\tstale-exempt.tsv:%d fragment matches %d rules, rules.sed lines %s: [%s]\n", FNR, c, hits, f } - ' "$STALE_ALLOW" + ' <(rule_records) "$STALE_ALLOW" } # The gate patterns live in omp-port/lib.sh, one source shared with the sync script. pattern_for() { @@ -359,6 +346,19 @@ else report "guide claims" "PASS ${#guide_skills[@]} skills, prototype and multi-phase-plan playbooks, check-plan.mjs, Feature Map, pin" fi +# Linted on every run, before the contracts-only exit, so a bad exemption fails the day it is +# written whether or not any rule is stale yet, and the fixture mutations can reach it. +exemptions=$(stale_exempt_resolve) +exempt_lines=$(awk -F'\t' '$1 == "ok" { print $2 }' <<<"$exemptions") +bad_frag=$(awk -F'\t' '$1 == "bad" { print $2 }' <<<"$exemptions") +if [ -n "$bad_frag" ]; then + report "stale exemptions" "FAIL" + while read -r l; do [ -n "$l" ] && violate "$l"; done <<<"$bad_frag" + violate "an exemption must name exactly one rules.sed rule by a fragment of 20 or more characters, and any other fragment exempts nothing; starting it with the rule's s# opener keeps it off other rules' replacement text" +else + report "stale exemptions" "PASS $(grep -c . <<<"$exempt_lines") fragment(s), each naming exactly one rule" +fi + if [ "${CHECK_PORT_CONTRACTS_ONLY:-}" = 1 ]; then echo if [ "$fail" -eq 0 ]; then echo "check-port contracts: PASS"; else echo "check-port contracts: FAIL"; fi @@ -726,7 +726,7 @@ if [ "$canon_ok" = yes ]; then unexempted="" while IFS=$'\t' read -r n rule; do [ -n "$n" ] || continue - exempt "$rule" || unexempted="${unexempted}rules.sed:${n}: ${rule}"$'\n' + grep -qxF "$n" <<<"$exempt_lines" || unexempted="${unexempted}rules.sed:${n}: ${rule}"$'\n' done <<<"$stale" total=$(grep -c . <<<"$stale" || true) if [ -n "$unexempted" ]; then @@ -746,17 +746,6 @@ else report "rule staleness" "SKIP needs the clone at $CANON" fi -# A fragment that is blank or shorter than the matcher requires exempts nothing at all, which reads -# like a working exemption and is not one. Linted separately so the mistake surfaces whether or not -# any rule is currently stale. -bad_frag=$(stale_exempt_bad) -if [ -n "$bad_frag" ]; then - report "stale exemptions" "FAIL" - while read -r l; do [ -n "$l" ] && violate "$l"; done <<<"$bad_frag" - violate "an exemption fragment that is blank or under 20 characters exempts nothing, so the rule it was written for will fail for the wrong reason" -else - report "stale exemptions" "PASS $(grep -cvE '^[[:space:]]*(#|$)' "$STALE_ALLOW" 2>/dev/null || echo 0) fragment(s), all usable" -fi rm -rf "$scratch" "$buildlog" echo diff --git a/omp-port/rules.sed b/omp-port/rules.sed index fcacceb..c798e51 100644 --- a/omp-port/rules.sed +++ b/omp-port/rules.sed @@ -155,7 +155,6 @@ s#`readonly: true`#read-only posture carried in the brief#g s#`readonly: false`#full tools per spawn#g s#agent mode \(`readonly: false`\)#full tools per spawn#g s#`readonly`: `true`#read-only posture carried in the brief#g -s#`run_in_background: true`#one `task` call with all items in `tasks[]`#g s#readonly strips MCPs\.#nothing strips MCPs here.#g # A judge is spawned with read posture in its brief, never as a field. Matched on the field name # rather than the sentence, because upstream rewords the sentence and the field never survives it. @@ -367,7 +366,6 @@ s#the `(architect|arena|swarm|interrogate) runners` line in the `pstack-models\. s#the `interrogate reviewers` line in the `pstack-models\.mdc` rule#one exact discovered reviewer agent per configured entry#g s#the `arena cross-judge pool` line in `~?/?\.cursor/rules/pstack-models\.mdc`#an exact discovered judge agent whose configured model differs from the parent's when the roster provides one, in `~/.omp/agent/config.yml`#g s#the `(architect|arena|swarm|interrogate) runners` line in `~?/?\.cursor/rules/pstack-models\.mdc`#the exact discovered runner agents in `~/.omp/agent/config.yml`#g -s#the `interrogate reviewers` line in `task\.agentModelOverrides` in `~/\.omp/agent/config\.yml`#one exact discovered reviewer agent per entry in `~/.omp/agent/config.yml`#g # The last two Cursor role labels. Same reasoning as the runner lines above: the label is a key # in a rule file that omp does not have, so naming it names nothing. The capability it stood for # is what the port writes instead. @@ -375,10 +373,9 @@ s#the `how (explorer|explainer)` line#an exact discovered \1 agent in `~/.omp/ag s#one per entry on the `interrogate reviewers` line in `~?/?(\.cursor/rules/pstack-models\.mdc|task\.agentModelOverrides in ~?/?\.omp/agent/config\.yml)`#one per configured entry in `~/.omp/agent/config.yml`#g s#Set `model` to that line.s value, or to the default if the rule or the line is missing\.#Each discovered agent's frontmatter or `task.agentModelOverrides[]` selects its model; the item has no `model` field.#g s#Leave `model` unset when the value is `auto` or `inherit-parent`\.#An `auto` or `inherit-parent` entry means the parent chat model, so omit that role from the map.#g -s#the `interrogate reviewers` line in `task\.agentModelOverrides` in `~?/?\.omp/agent/config\.yml`#one exact discovered reviewer agent per configured entry in `~/.omp/agent/config.yml`#g +s#the `interrogate reviewers` line in `~?/?\.cursor/rules/pstack-models\.mdc`#one exact discovered reviewer agent per configured entry in `~/.omp/agent/config.yml`#g s#the `architect runners` line in the `task\.agentModelOverrides` entries in `~/\.omp/agent/config\.yml`#the same exact discovered runner agents Arena uses, with the architect role in each brief, in `~/.omp/agent/config.yml`#g s#an exact discovered (why investigators|why synthesizer|interrogate reviewers|swarm workers|architect runners|arena runners|arena cross-judge pool|reflect tooling|reflect judgment, divergent, synthesizer) agent#an exact discovered agent filling that role#g -s#one per entry on one exact discovered reviewer agent per entry#one per configured entry#g # A single spawn is still one task call with one item and the required shared context. Matched on # the "spawn one " phrase, which is the shape upstream uses everywhere it means one worker, # rather than on the sentence around it. diff --git a/omp-port/stale-exempt.tsv b/omp-port/stale-exempt.tsv index e96d328..54057ff 100644 --- a/omp-port/stale-exempt.tsv +++ b/omp-port/stale-exempt.tsv @@ -32,14 +32,12 @@ The synthesizer's quality check includes spot-verifying ci superseded: upstream there is no task `readonly` field\. Use forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. there is no task `readonly` field\.[[:space:]]*$ forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. The synthesizer returns a structured Accepted / Rejected / superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. -After all Phase B candidates complete, choose an exact dis forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. +s#After all Phase B candidates complete, choose an exact dis forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. ^- `model`: the configured `interrogate reviewers` entry, forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. `subagent_type`: `([^`]+)` forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. -`subagent_type`: `([^`]+)` forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. -agent mode \(`readonly: false`\) forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. -^- `readonly`: `true`$ forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. +s#agent mode \(`readonly: false`\) forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. s#`run_in_background: true`#one forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. -`subagent_type: "poteto-agent"` forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. +s#`subagent_type: "poteto-agent"` forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. ^Spawn one investigator per category that has a matching M superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. One subagent per PR, not batched, each a Cursor cloud agen superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. `claude-fable-[0-9.-]+(-(thinking-)?[a-z]+)?` forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. @@ -70,9 +68,7 @@ For each candidate, read the first JSONL line and check th superseded: upstream the `arena cross-judge pool` line in the `pstack-models\.m forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. the `swarm workers` model and speed \(your fast code model forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. the `interrogate reviewers` line in the `pstack-models\.md forward-compat net: the left side is a Cursor wire token or path that upstream does not emit today. The rule earns its place by not firing, because it is the only thing standing between that text and a Cursor API in the tree. -the `interrogate reviewers` line in `task\.agentModelOverr superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. the `architect runners` line in the `task\.agentModelOverr superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. -one per entry on one exact discovered reviewer agent per e superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. `agent`: an exact discovered (how explorer|how explainer) superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. re-read this playbook from trunk with `git show origin/mai superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. Re-read the execution playbook from trunk and the armed /g superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. @@ -85,8 +81,8 @@ push with `--force-with-lease` only after an `ls-remote` c superseded: upstream The operator's full-autonomy grant plus the root's clean v superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. Recompute `frontier.json` from `gt` after every merge and superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. - Workers never rebase and never run `gt`\. superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. -The strict `scout` definition has no MCP grant, so never a superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. -an exact discovered full-access worker for citation spot-c superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. +s#The strict `scout` definition has no MCP grant, so never a superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. +s#an exact discovered full-access worker for citation spot-c superseded: upstream reworded this sentence and a different rule now covers the current phrasing. Kept because the reword is cheap for upstream to undo. an exact discovered (why investigators|why synthesizer|interrogate reviewers second-order net: it rewrites the output of the `model`: the `` line token rules, so raw upstream never matches it. At 4ec7ffa the whole-sentence rules for why and reflect claim the last role-line sentences first, so the token rules emit nothing for it to clean. Kept because a reworded sentence upstream falls through to those token rules, and this is what turns their role label into a capability. `readonly`: `true`#read-only posture carried in the brief forward-compat net: the Cursor wire field as it appears mid-sentence rather than as a role bullet. Upstream emits no such line today; the rule is what keeps it from reaching the tree if it ever does. \ No newline at end of file diff --git a/omp-port/test-gate-mutations.sh b/omp-port/test-gate-mutations.sh index 1e9e0ec..04e928c 100755 --- a/omp-port/test-gate-mutations.sh +++ b/omp-port/test-gate-mutations.sh @@ -4,17 +4,20 @@ set -uo pipefail PORT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) REPO_ROOT=$(cd "$PORT_DIR/.." && pwd -P) FIXTURE=$(mktemp -d) -trap 'rm -rf "$FIXTURE"' EXIT +PORT_FIXTURE=$(mktemp -d) +trap 'rm -rf "$FIXTURE" "$PORT_FIXTURE"' EXIT fail=0 reset_fixture() { rm -rf "$FIXTURE" mkdir -p "$FIXTURE" cp -a "$REPO_ROOT/plugins/pstack/." "$FIXTURE/" + cp "$PORT_DIR/rules.sed" "$PORT_DIR/stale-exempt.tsv" "$PORT_FIXTURE/" } run_contracts() { - CHECK_PORT_ROOT="$FIXTURE" CHECK_PORT_CONTRACTS_ONLY=1 bash "$PORT_DIR/check-port.sh" + CHECK_PORT_ROOT="$FIXTURE" RULES="$PORT_FIXTURE/rules.sed" STALE_EXEMPT="$PORT_FIXTURE/stale-exempt.tsv" \ + CHECK_PORT_CONTRACTS_ONLY=1 bash "$PORT_DIR/check-port.sh" } reset_fixture @@ -133,6 +136,17 @@ mutate_veto_provenance() { expect_failure 'external veto provenance' 'external capability provenance' mutate_veto_provenance +# A fragment written for one stale rule once matched a second rule too and kept it from failing the +# gate. Copying the rule the first exemption names gives that fragment two rules to match. +mutate_shared_fragment() { + local frag rule + frag=$(awk -F'\t' '$0 !~ /^#/ && $0 != "" { print $1; exit }' "$PORT_FIXTURE/stale-exempt.tsv") + rule=$(grep -m1 -F -- "$frag" "$PORT_FIXTURE/rules.sed") + printf '%s\n' "$rule" >>"$PORT_FIXTURE/rules.sed" +} + +expect_failure 'shared exemption fragment' 'stale exemptions' mutate_shared_fragment + if [ "$fail" -ne 0 ]; then printf 'mutation tests: FAIL\n'