featureparity 0.0.6 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/fp/parity.rb ADDED
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
4
+ # Presentation vocabulary for the parity grid.
5
+ #
6
+ # These are the cell states the API's parity report can return, with the glyph and
7
+ # ordering used to render them. This list is a copy of the server's
8
+ # ParityReport::STATE_SEVERITY keys, which is unavoidable across the gem/API boundary
9
+ # — but it is only ever used for *display*: which glyph to draw, and what order to
10
+ # list counts in.
11
+ #
12
+ # The CLI deliberately does not decide what a violation is. The server returns
13
+ # `violations` and `passing` already computed, and `fp check` exits on those. If it
14
+ # decided locally, every repo would enforce whichever gem version its CI installed,
15
+ # and raising a project's level would silently do nothing until every consumer
16
+ # upgraded. So an unknown state arriving from a newer API degrades to a bare glyph
17
+ # here rather than changing any verdict.
18
+ module Parity
19
+ # Worst → best, matching the server's severity order. Used for stable output.
20
+ STATES = %w[failing missing suspect stub present pr_passing passing].freeze
21
+
22
+ GLYPHS = {
23
+ 'passing' => '✓',
24
+ 'pr_passing' => '◐',
25
+ 'failing' => '✗',
26
+ 'present' => '●',
27
+ 'stub' => '○',
28
+ 'suspect' => '⚠',
29
+ 'missing' => '·'
30
+ }.freeze
31
+
32
+ # Drawn for a surface a requirement does not require — not a state, an absence.
33
+ NOT_REQUIRED = '-'
34
+
35
+ # Marks a requirement whose proposed change is awaiting human review. Not a cell
36
+ # state: the requirement is still measured, and its cells still say what the evidence
37
+ # says. It's a row-level annotation so a reader can tell "uncovered" from "covered,
38
+ # but the wording just moved under the tests".
39
+ UNDER_REVIEW = '✎'
40
+
41
+ # Fallback for a state this gem version doesn't know about.
42
+ UNKNOWN_GLYPH = '?'
43
+
44
+ LEVELS = %w[off regression covered strict].freeze
45
+
46
+ LEGEND = 'Legend: ✓ passing ◐ pr-passing ✗ failing ● present ○ stub ⚠ suspect · missing - not required'
47
+
48
+ # Appended to the legend only when the grid actually contains one, so the common
49
+ # case isn't cluttered by vocabulary nothing on screen uses.
50
+ UNDER_REVIEW_LEGEND = "#{UNDER_REVIEW} awaiting review of a proposed change"
51
+
52
+ def self.glyph(state)
53
+ GLYPHS.fetch(state.to_s, UNKNOWN_GLYPH)
54
+ end
55
+
56
+ # Row-level annotation for a requirement, or nil when there's nothing to say.
57
+ def self.row_marker(row)
58
+ UNDER_REVIEW if row['under_review'] || row[:under_review]
59
+ end
60
+
61
+ # "2 modifications, 1 deletion" for a pending_reviews list.
62
+ def self.pending_summary(pending_reviews)
63
+ kinds = Array(pending_reviews).map { |p| p['kind'] || p[:kind] }
64
+ removals = kinds.count('removal')
65
+ modifications = kinds.size - removals
66
+
67
+ parts = []
68
+ parts << "#{modifications} modification#{'s' unless modifications == 1}" if modifications.positive?
69
+ parts << "#{removals} deletion#{'s' unless removals == 1}" if removals.positive?
70
+ parts.join(', ')
71
+ end
72
+
73
+ # State counts in severity order, skipping zeros, as "✗ 2 failing" strings.
74
+ def self.count_labels(summary)
75
+ STATES.filter_map do |state|
76
+ count = summary[state].to_i
77
+ "#{glyph(state)} #{count} #{state}" if count.positive?
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Fp
6
+ # ProjectConfig — the repo-local `.featureparity.yml`, which marks a checkout as
7
+ # tracked by FeatureParity and says which project it belongs to.
8
+ #
9
+ # This is deliberately a different thing from Fp::Config (`~/.config/fp/config.yml`).
10
+ # That one is per-machine and secret-bearing: API keys, profiles, and where each
11
+ # surface lives on *this* laptop. This one is committed to the repo and answers a
12
+ # question the machine can't: "which FeatureParity project is this code?"
13
+ #
14
+ # It exists because enforcement has to work in CI and in an agent's first minute in a
15
+ # fresh clone, where nobody has run `fp setup`. Without it, every workflow file and
16
+ # every agent prompt has to hardcode `--project <slug>`, and the answer drifts. With
17
+ # it, `fp check` in a fresh clone already knows what to check.
18
+ #
19
+ # # .featureparity.yml
20
+ # project: feature-parity
21
+ # enforcement: regression
22
+ # surface: api
23
+ #
24
+ # Discovery walks up from the working directory to the filesystem root, so the file
25
+ # is found from a subdirectory too (like .git or package.json).
26
+ class ProjectConfig
27
+ FILENAME = '.featureparity.yml'
28
+
29
+ # Also accepted when reading, so a repo that picked the dotless or .yaml spelling
30
+ # isn't silently ignored. `fp init` only ever writes FILENAME.
31
+ ALTERNATE_FILENAMES = ['.featureparity.yaml', 'featureparity.yml'].freeze
32
+
33
+ CANDIDATE_FILENAMES = ([FILENAME] + ALTERNATE_FILENAMES).freeze
34
+
35
+ attr_reader :path, :data
36
+
37
+ def initialize(path: nil, data: nil)
38
+ @path = path
39
+ @data = data || {}
40
+ end
41
+
42
+ # Find and load the nearest .featureparity.yml at or above `dir`.
43
+ # Always returns a ProjectConfig — an absent file is an empty one, so callers can
44
+ # treat "no config" and "config without this key" the same way.
45
+ def self.discover(dir = Dir.pwd)
46
+ found = find_file(dir)
47
+ return new unless found
48
+
49
+ new(path: found, data: parse(found))
50
+ end
51
+
52
+ # Walk up from `dir` looking for any candidate filename. Stops at the filesystem
53
+ # root; File.dirname('/') == '/' is the loop's terminator.
54
+ def self.find_file(dir)
55
+ current = File.expand_path(dir)
56
+
57
+ loop do
58
+ CANDIDATE_FILENAMES.each do |name|
59
+ candidate = File.join(current, name)
60
+ return candidate if File.file?(candidate)
61
+ end
62
+
63
+ parent = File.dirname(current)
64
+ return nil if parent == current
65
+
66
+ current = parent
67
+ end
68
+ end
69
+
70
+ # A malformed config must not take the CLI down — warn and behave as if absent,
71
+ # so a bad merge in .featureparity.yml doesn't break every fp command in the repo.
72
+ def self.parse(file)
73
+ loaded = YAML.safe_load_file(file, permitted_classes: [Symbol])
74
+ loaded.is_a?(Hash) ? loaded : {}
75
+ rescue StandardError => e
76
+ warn "Warning: Could not load #{file}: #{e.message}"
77
+ {}
78
+ end
79
+
80
+ # Serialize a config file body. Kept here (rather than in `fp init`) so the reader
81
+ # and the writer of this format live next to each other.
82
+ def self.render(project:, enforcement: nil, surface: nil)
83
+ body = +<<~HEADER
84
+ # FeatureParity — this repo is tracked for feature parity.
85
+ # Committed on purpose: it is how CI and agents know which project this code
86
+ # belongs to without anyone passing --project. See https://featureparity.dev
87
+ ---
88
+ project: #{project}
89
+ HEADER
90
+
91
+ body << "\n# Enforcement level the CI gate applies (off, regression, covered, strict).\n" \
92
+ "# Omit to use whatever the project is configured with server-side.\n" \
93
+ "enforcement: #{enforcement}\n" if enforcement
94
+
95
+ body << "\n# Default surface for this repo, used when --surface is not given.\n" \
96
+ "surface: #{surface}\n" if surface
97
+
98
+ body
99
+ end
100
+
101
+ def exists?
102
+ !path.nil?
103
+ end
104
+
105
+ # Project slug this checkout belongs to.
106
+ def project
107
+ fetch_string('project')
108
+ end
109
+
110
+ # Enforcement level override committed alongside the code. Nil means "use the
111
+ # project's configured level" — the normal case, since the point of storing the
112
+ # level server-side is that it only has to be raised in one place.
113
+ def enforcement
114
+ fetch_string('enforcement')
115
+ end
116
+
117
+ # Default surface for a single-surface repo. A monorepo leaves this out and passes
118
+ # --surface per job instead.
119
+ def surface
120
+ fetch_string('surface')
121
+ end
122
+
123
+ def to_h
124
+ { 'path' => path }.merge(data)
125
+ end
126
+
127
+ private
128
+
129
+ def fetch_string(key)
130
+ value = data[key]
131
+ return nil if value.nil?
132
+
133
+ string = value.to_s.strip
134
+ string.empty? ? nil : string
135
+ end
136
+ end
137
+ end
data/lib/fp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fp
4
- VERSION = '0.0.6'
4
+ VERSION = '0.0.8'
5
5
  end
data/lib/fp.rb CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  require_relative 'fp/version'
4
4
  require_relative 'fp/config'
5
+ require_relative 'fp/project_config'
6
+ require_relative 'fp/parity'
5
7
  require_relative 'fp/client'
6
8
  require_relative 'fp/junit'
7
9
  require_relative 'fp/output'
data/skills/fp/SKILL.md CHANGED
@@ -45,10 +45,11 @@ If not set, ask the user for their API key or have them run `fp setup`.
45
45
 
46
46
  ```bash
47
47
  fp list --project stowzilla # All active requirements
48
- fp list --project stowzilla --gaps # Requirements without evidence
48
+ fp list --project stowzilla --gaps # Requirements with a required surface lacking evidence
49
49
  fp show <slug> --project stowzilla # Requirement details
50
50
  fp matrix --project stowzilla # ASCII parity matrix
51
51
  fp matrix --project stowzilla --csv # Export as CSV
52
+ fp check --project stowzilla # Enforce parity (exits 1 on violations)
52
53
  ```
53
54
 
54
55
  ### 2. Add the fp:<slug> marker to tests
@@ -111,23 +112,143 @@ fp report --junit junit.xml \
111
112
  fp propose --project stowzilla \
112
113
  --slug print_container_qr \
113
114
  --name "Print QR on container label" \
114
- --why "Enables scanning containers in the warehouse" \
115
+ --why "Staff scan the label to find a container; without a QR they key the ID by hand and mis-type it" \
115
116
  --required api,customer_android \
116
- --acceptance "Label shows scannable QR code"
117
+ --acceptance "QR scans to the container's detail page"
117
118
  ```
118
119
 
120
+ **Writing `--why`: brief, and it actually explains why.** Whoever approves this
121
+ requirement should grasp in one read why the product needs it — the user problem it
122
+ solves or the outcome it protects. State that and stop. Longer is fine when it earns
123
+ its length; wordy is not. Avoid two things: filler that says nothing specific, and
124
+ describing *how* the feature works instead of *why* anyone wants it.
125
+
126
+ - ✅ `--why "Staff scan the label to find a container; without a QR they key the ID by hand and mis-type it."`
127
+ - ❌ `--why "Ensures a high-quality experience across all surfaces."` (filler — says nothing specific)
128
+ - ❌ `--why "The label renders a QR image encoding the container ID at print time."` (how it works, not why anyone needs it)
129
+
130
+ Same rule for `--acceptance`: the observable condition that proves it's done, not a
131
+ restatement of the title.
132
+
119
133
  **Agents always create requirements as draft.** A human must activate in the web app.
120
134
 
135
+ ### 6. Propose a change to an existing requirement
136
+
137
+ When a requirement no longer says the right thing, don't silently edit it — propose the
138
+ change and say what prompted it. The requirement drops back to **draft** and a human
139
+ reviews the old-vs-new diff before it counts again.
140
+
141
+ ```bash
142
+ fp propose-change print_container_qr --project stowzilla \
143
+ --why "Labels must survive a freezer" \
144
+ --acceptance "QR scans after 24h at -20C" \
145
+ --reason "Cold-chain rollout changed the requirement" \
146
+ --source-type fizzy \
147
+ --source-url https://app.fizzy.do/6098707/cards/1377
148
+ ```
149
+
150
+ To propose deleting one instead (nothing is destroyed — a human decides):
151
+
152
+ ```bash
153
+ fp propose-change print_container_qr --project stowzilla \
154
+ --removal --reason "Superseded by print_container_label"
155
+ ```
156
+
157
+ Rules:
158
+
159
+ - Always pass `--reason` and, where one exists, `--source-type`/`--source-url`. The whole
160
+ point of a proposal is that the *why* is recorded next to the *what*.
161
+ - Agents cannot change `--slug` (it's the join to every `fp:<slug>` marker) or remove a
162
+ required surface. Both come back as a 403, printed verbatim.
163
+ - Agents cannot approve or reject. Only humans review.
164
+ - One open proposal per requirement — review the current one before proposing another.
165
+
166
+ ### 7. See what changed and why
167
+
168
+ ```bash
169
+ fp history print_container_qr --project stowzilla
170
+ fp history print_container_qr --project stowzilla --pending # just what's awaiting review
171
+ ```
172
+
173
+ Shows the timeline (who changed what, when, and what prompted it), the field-by-field
174
+ diff, and a diff of the **test evidence** — so you can see which tests were written
175
+ against wording that has since moved.
176
+
177
+ ### 8. Verify you didn't break the gate
178
+
179
+ ```bash
180
+ fp check # exits non-zero on violations
181
+ fp check --surface api --json # machine-readable violations
182
+ ```
183
+
184
+ `fp check` is the enforcement gate. It asks the API whether the project violates its
185
+ enforcement level and **exits 1 if it does**, which is what makes it usable as a required
186
+ status check on a pull request. Run it after reporting evidence to confirm your change
187
+ left parity intact.
188
+
189
+ Levels, from most to least forgiving:
190
+
191
+ | Level | Fails the build when |
192
+ |-------|----------------------|
193
+ | `off` | never — report only |
194
+ | `regression` | a test that was passing has gone red |
195
+ | `covered` | any required surface lacks real (non-stub) evidence |
196
+ | `strict` | any required surface isn't green in CI |
197
+
198
+ The level is configured on the project, so don't hardcode one. `--level` overrides it for
199
+ a single run; `--warn-only` prints violations but still exits 0.
200
+
201
+ **A requirement you proposed a change to still counts.** It drops to `draft` until a human
202
+ reviews it, but the gate keeps measuring it — otherwise proposing a change would be a way
203
+ to make a red gate green. What relaxes is only evidence *freshness*: proposing marks the
204
+ existing evidence `suspect`, and `suspect` doesn't fail a build below `strict`. Re-write the
205
+ tests against the new wording and re-report to clear it. `fp check` lists everything
206
+ awaiting review so you can tell that apart from a plain gap.
207
+
208
+ **Gating a branch on its own results.** Plain `fp check` sees only what has been recorded,
209
+ which on a pull request is the default branch's state — it cannot see what your branch
210
+ changed. Pass the run's JUnit to evaluate your results without saving them:
211
+
212
+ ```bash
213
+ fp check --surface api --junit junit.xml
214
+ ```
215
+
216
+ This also catches the one regression a green test suite can't: a test that was deleted,
217
+ renamed, or had its `fp:` marker stripped. Every remaining test passes, so CI is happy,
218
+ while the requirement quietly lost its only proof.
219
+
220
+ ### Repo-local config (`.featureparity.yml`)
221
+
222
+ If a repo has a `.featureparity.yml`, `--project` is optional — `fp` reads the slug from
223
+ it. Check for one before asking the user which project to use:
224
+
225
+ ```bash
226
+ cat .featureparity.yml 2>/dev/null
227
+ ```
228
+
229
+ To onboard a repo that has no such file (writes the config, a CI gate workflow, and a
230
+ FeatureParity section in `AGENTS.md`):
231
+
232
+ ```bash
233
+ fp init --project <slug> --surface <surface>
234
+ ```
235
+
121
236
  ## Quick reference
122
237
 
123
238
  | Task | Command |
124
239
  |------|---------|
240
+ | Onboard a repo | `fp init --project X` |
125
241
  | List requirements | `fp list --project X` |
126
242
  | Show gaps | `fp list --project X --gaps` |
127
243
  | Show details | `fp show <slug> --project X` |
128
244
  | Report evidence | `fp report <slug> --project X --surface Y --file Z --repo A/B` |
129
245
  | Report from JUnit | `fp report --junit file.xml --project X --surface Y --repo A/B` |
130
246
  | Propose requirement | `fp propose --project X --slug Y --name "..."` |
247
+ | Propose a change | `fp propose-change <slug> --project X --why "..." --reason "..."` |
248
+ | Propose a deletion | `fp propose-change <slug> --project X --removal --reason "..."` |
249
+ | View change history | `fp history <slug> --project X` |
250
+ | Enforce parity | `fp check --project X` |
251
+ | Gate on this run | `fp check --project X --surface Y --junit file.xml` |
131
252
  | View matrix | `fp matrix --project X` |
132
253
  | List surfaces | `fp surfaces --project X` |
133
254
  | List projects | `fp projects` |
@@ -49,17 +49,22 @@ List requirements.
49
49
 
50
50
  ```bash
51
51
  fp list --project stowzilla # All active requirements
52
- fp list --project stowzilla --gaps # Requirements without evidence
52
+ fp list --project stowzilla --gaps # Requirements with a required surface lacking evidence
53
53
  fp list --project stowzilla --status draft
54
54
  fp list --project stowzilla --json
55
55
  ```
56
56
 
57
57
  | Flag | Description |
58
58
  |------|-------------|
59
- | `--project` | Project slug (required) |
60
- | `--gaps` | Show only requirements without evidence |
59
+ | `--project` | Project slug (optional if `.featureparity.yml` declares one) |
60
+ | `--gaps` | Show only requirements with an uncovered required surface |
61
61
  | `--status` | Filter by status: active, draft, archived |
62
62
  | `--category` | Filter by category |
63
+ | `--surface` | Restrict to one surface |
64
+
65
+ `--gaps` reports a requirement when one of its required surfaces has no real evidence —
66
+ missing, stubbed, or failing. The table's UNCOVERED column names those surfaces and their
67
+ state.
63
68
 
64
69
  ### fp show
65
70
 
@@ -131,9 +136,9 @@ Propose a new requirement as draft.
131
136
  fp propose --project stowzilla \
132
137
  --slug print_container_qr \
133
138
  --name "Print QR on container label" \
134
- --why "Enables scanning containers" \
139
+ --why "Staff scan the label to find a container; without a QR they key the ID by hand and mis-type it" \
135
140
  --required api,customer_android \
136
- --acceptance "Label shows scannable QR code"
141
+ --acceptance "QR scans to the container's detail page"
137
142
  ```
138
143
 
139
144
  | Flag | Required | Description |
@@ -141,22 +146,205 @@ fp propose --project stowzilla \
141
146
  | `--project` | Yes | Project slug |
142
147
  | `--slug` | Yes | Requirement slug (immutable, choose carefully) |
143
148
  | `--name` | Yes | Human-readable name |
144
- | `--why` | No | Why this requirement matters |
149
+ | `--why` | No | One plain sentence: the user need or outcome the feature serves (see below) |
145
150
  | `--required` | No | Comma-separated surfaces that must implement this |
146
- | `--acceptance` | No | How to verify completion |
151
+ | `--acceptance` | No | The observable condition that proves it's done |
152
+
153
+ **Writing `--why`:** brief, and it actually explains why. Whoever approves this should
154
+ grasp in one read the user need or outcome the feature serves. Longer is fine when it
155
+ earns its length; wordy is not. Avoid filler, and avoid describing *how* it works
156
+ instead of *why* anyone wants it.
157
+
158
+ - ✅ `"Staff scan the label to find a container; without a QR they key the ID by hand and mis-type it."`
159
+ - ❌ `"Ensures a high-quality experience across all surfaces."` (filler)
160
+ - ❌ `"The label renders a QR image encoding the container ID at print time."` (how it works, not why)
147
161
 
148
162
  **Agents always create requirements as draft.** A human must activate in the web app.
149
163
 
164
+ ### fp propose-change
165
+
166
+ Propose a change to a requirement that already exists — or propose deleting it.
167
+ Recorded with what the requirement *was*, a field-by-field diff, and what prompted the
168
+ change. The requirement drops back to **draft** until a human approves or rejects.
169
+
170
+ ```bash
171
+ fp propose-change print_container_qr --project stowzilla \
172
+ --why "Labels must survive a freezer" \
173
+ --acceptance "QR scans after 24h at -20C" \
174
+ --reason "Cold-chain rollout" \
175
+ --source-type fizzy \
176
+ --source-url https://app.fizzy.do/6098707/cards/1377
177
+
178
+ # Propose deletion instead
179
+ fp propose-change print_container_qr --project stowzilla \
180
+ --removal --reason "Superseded by print_container_label"
181
+ ```
182
+
183
+ | Flag | Required | Description |
184
+ |------|----------|-------------|
185
+ | `<slug>` | Yes | Positional: the requirement to change |
186
+ | `--project` | No | Project slug (optional if `.featureparity.yml` declares one) |
187
+ | `--removal` | No | Propose deleting the requirement instead of modifying it |
188
+ | `--name` | No | Proposed title |
189
+ | `--why` | No | Proposed justification |
190
+ | `--acceptance` | No | Proposed acceptance criteria |
191
+ | `--required` | No | Proposed surfaces (comma-separated; agents cannot remove existing ones) |
192
+ | `--category` | No | Proposed category |
193
+ | `--parent` | No | Proposed parent requirement, by slug |
194
+ | `--slug` | No | Proposed new slug — **humans only**; breaks existing `fp:<slug>` markers |
195
+ | `--reason` | No | Why the change is needed |
196
+ | `--source-type` | No | `fizzy`, `discord`, `github_issue`, `jira`, `notion`, `manual`, `ai_chat` |
197
+ | `--source-url` | No | Link to the card/message/issue that prompted it |
198
+ | `--source-context` | No | Free-text context |
199
+
200
+ At least one proposed value (or `--removal`) is required — a bare invocation would knock a
201
+ live requirement back to draft for nothing, so it errors instead.
202
+
203
+ Refusals, all printed verbatim:
204
+
205
+ | Situation | Status |
206
+ |-----------|--------|
207
+ | Agent tries to change `--slug` | 403 |
208
+ | Agent tries to remove a required surface | 403 |
209
+ | Proposed values match the current text | 422 |
210
+ | Requirement already has a proposal awaiting review | 409 |
211
+ | Requirement is archived | 422 |
212
+
213
+ **Agents cannot approve or reject.** Reviewing is a human action in the web app.
214
+
215
+ ### fp history
216
+
217
+ Show a requirement's change history: what changed, who changed it, what prompted it, and
218
+ how the test evidence moved.
219
+
220
+ ```bash
221
+ fp history print_container_qr --project stowzilla
222
+ fp history print_container_qr --project stowzilla --pending # only what awaits review
223
+ fp history print_container_qr --project stowzilla --limit 5 # most recent 5 entries
224
+ fp history print_container_qr --project stowzilla --json
225
+ ```
226
+
227
+ Entries are printed oldest first. Each shows the event (created, edited, approved,
228
+ change proposed, deletion proposed, change approved/rejected, archived, deleted), the
229
+ actor and whether they were a human or an agent, the field diff, the evidence diff, and
230
+ the review decision where one was made. An open proposal is called out at the end.
231
+
232
+ `--project` is optional when the repo has a `.featureparity.yml`.
233
+
150
234
  ### fp matrix
151
235
 
152
- Display the parity matrix.
236
+ Display the parity matrix. Cells show the state of the evidence filed for each
237
+ (requirement, surface) pair.
153
238
 
154
239
  ```bash
155
240
  fp matrix --project stowzilla # ASCII table
156
- fp matrix --project stowzilla --csv # CSV export
241
+ fp matrix --project stowzilla --csv # CSV export (state names, not glyphs)
157
242
  fp matrix --project stowzilla --json # JSON
243
+ fp matrix --project stowzilla --surface api
244
+ ```
245
+
246
+ | Glyph | State | Meaning |
247
+ |-------|-------|---------|
248
+ | `✓` | passing | CI ran the test and it was green |
249
+ | `✗` | failing | CI ran the test and it was red, or the marker vanished |
250
+ | `●` | present | an agent found the `fp:` marker; no CI verdict yet |
251
+ | `○` | stub | reported as a stub / skipped test |
252
+ | `⚠` | suspect | the requirement changed; evidence needs re-verification |
253
+ | `·` | missing | no evidence for this surface |
254
+ | `-` | — | the requirement does not require this surface |
255
+
256
+ A slug suffixed with `✎` has a proposed change awaiting review — it is still measured by
257
+ the gate, and its evidence is `suspect` until someone reviews the change. `--csv` writes
258
+ `<slug> (under review)` instead of the glyph.
259
+
260
+ ### fp check
261
+
262
+ The enforcement gate. Exits **1** when the project violates its enforcement level, so it
263
+ can be a required status check on a pull request.
264
+
265
+ ```bash
266
+ fp check # project from .featureparity.yml
267
+ fp check --project stowzilla --surface api
268
+ fp check --level covered # override the project's level for this run
269
+ fp check --warn-only # print violations, still exit 0
270
+ fp check --json
271
+ fp check --surface api --junit junit.xml # gate on this run's results (preview)
158
272
  ```
159
273
 
274
+ | Flag | Description |
275
+ |------|-------------|
276
+ | `--project` | Project slug (optional if `.featureparity.yml` declares one) |
277
+ | `--surface` | Gate one surface only |
278
+ | `--level` | Override the project's enforcement level |
279
+ | `--junit` | Evaluate this run's JUnit results without saving them (repeatable) |
280
+ | `--warn-only` | Report violations but exit 0 |
281
+
282
+ **Enforcement levels** (the level is configured on the project; don't hardcode one):
283
+
284
+ | Level | A build fails when |
285
+ |-------|--------------------|
286
+ | `off` | never — report only |
287
+ | `regression` | a test that was passing has gone red |
288
+ | `covered` | any required surface lacks real (non-stub) evidence |
289
+ | `strict` | any required surface isn't green in CI |
290
+
291
+ `suspect` only violates at `strict`: it means the requirement's wording moved, not that
292
+ the test broke.
293
+
294
+ **Requirements awaiting review still count.** `fp propose-change` demotes a requirement to
295
+ `draft`, but the gate keeps measuring it — otherwise proposing a change would be a way to
296
+ turn a red gate green. Coverage is still demanded; only evidence freshness relaxes, because
297
+ proposing marks the evidence `suspect`. `fp check` lists them separately from violations:
298
+
299
+ ```
300
+ 1 requirement(s) awaiting review of a proposed change (1 modification):
301
+ SLUG PROPOSED TITLE
302
+ print_qr modification Print QR on container label
303
+ These still count toward the gate — proposing a change is not a way out of it.
304
+ ```
305
+
306
+ `fp matrix` marks the same rows with `✎`, and `fp list` names them even when the default
307
+ `--status active` filter would otherwise hide them.
308
+
309
+ **Preview mode (`--junit`).** Without it, `fp check` sees only what has been recorded —
310
+ on a pull request, that's the default branch's state, not what the branch changed. With
311
+ it, the run's results are applied on top of the recorded grid and then discarded, so a
312
+ branch is judged on its own results without writing evidence that would churn the live
313
+ matrix for everyone.
314
+
315
+ Preview also catches the regression a green suite can't: a test that was deleted,
316
+ renamed, or had its `fp:` marker removed. The remaining tests all pass, so CI is happy,
317
+ while the requirement silently lost its only proof. Pass one `--junit` per surface —
318
+ `--surface` tells the server which cells the run had a chance to cover, and pooling
319
+ surfaces into one call would misattribute vanished tests.
320
+
321
+ ### fp init
322
+
323
+ Onboard a repo onto FeatureParity. Writes three files and makes no API calls (so it works
324
+ before a key is configured).
325
+
326
+ ```bash
327
+ fp init --project stowzilla --surface api
328
+ fp init --project stowzilla --no-workflow # skip the CI workflow
329
+ fp init --project stowzilla --no-agents # skip the AGENTS.md section
330
+ fp init --project stowzilla --force # refresh files written previously
331
+ fp init --project stowzilla --dir path/to/repo
332
+ ```
333
+
334
+ | File | Purpose |
335
+ |------|---------|
336
+ | `.featureparity.yml` | declares the project slug, so `--project` becomes optional |
337
+ | `.github/workflows/feature-parity.yml` | the CI gate |
338
+ | `AGENTS.md` (appended) | instructions telling agents to mark and report their tests |
339
+
340
+ Existing files are skipped unless `--force`. The `AGENTS.md` section is appended between
341
+ marker comments and recognised on re-run, so it is never duplicated.
342
+
343
+ The `AGENTS.md` section tells agents to bind a test to a requirement with `fp:<slug>`, to
344
+ propose a new requirement when none covers the work, and to use `fp propose-change`
345
+ instead of editing or ignoring a requirement that no longer says the right thing — plus
346
+ the guards they would otherwise learn from a 403.
347
+
160
348
  ### fp profile
161
349
 
162
350
  Manage named profiles. Each profile stores an API key and (optionally) the
@@ -222,14 +410,32 @@ fp repos unset customer_android --project stowzilla
222
410
  |------|---------|
223
411
  | 0 | Success |
224
412
  | 1 | General error (invalid flags, missing required args, API error) |
413
+ | 1 | `fp check`: the parity gate failed (violations) |
414
+ | 130 | Interrupted (Ctrl-C) |
415
+
416
+ `fp check` deliberately reuses exit 1 for a failed gate rather than a distinct code: CI
417
+ treats any non-zero as a failure, and a special code would only invite scripts to ignore
418
+ it. Use `--json` to distinguish a gate failure (`ok: true`, `passing: false`) from a
419
+ request failure (`ok: false`).
225
420
 
226
421
  ## Configuration files
227
422
 
228
- - `~/.config/fp/config.yml` — Profiles and global settings
423
+ - `~/.config/fp/config.yml` — Profiles and global settings (per-machine, holds API keys)
424
+ - `.featureparity.yml` — Repo-local, committed. Declares which project a checkout belongs
425
+ to so CI and agents don't need `--project`. Discovered by walking up from the working
426
+ directory. Written by `fp init`.
229
427
  - `FP_API_KEY` environment variable overrides all profiles
230
428
  - `FP_API_URL` environment variable overrides API URL
231
429
  - `FP_PROFILE` environment variable selects a profile
232
430
 
431
+ ### .featureparity.yml
432
+
433
+ ```yaml
434
+ project: stowzilla # required — the project slug
435
+ enforcement: regression # optional — usually omit; the level lives on the project
436
+ surface: api # optional — default surface for a single-surface repo
437
+ ```
438
+
233
439
  ## API URL priority
234
440
 
235
441
  1. `--api-url` flag