featureparity 0.0.13 → 0.0.15

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/junit.rb CHANGED
@@ -32,8 +32,13 @@ module Fp
32
32
  # One requirement's aggregated CI outcome for one surface, ready to hand to the
33
33
  # backend's ingest endpoint. This is what lets CI upload *resolved* results rather
34
34
  # than raw XML the server would have to re-scrape the repo to understand.
35
- CiResult = Struct.new(:slug, :surface, :status, :file, :line, :test_count, :failure_count,
36
- keyword_init: true)
35
+ #
36
+ # `title` names a representative test — the first one that *failed* when the verdict
37
+ # is `failing`, otherwise the first that ran. Without it the evidence row keeps
38
+ # whatever title an earlier agent report left behind, and the web app then blames a
39
+ # test that is perfectly green for the failure.
40
+ CiResult = Struct.new(:slug, :surface, :test_type, :status, :file, :line, :title, :test_count,
41
+ :failure_count, keyword_init: true)
37
42
 
38
43
  # A failing testcase that carries no fp:<slug> marker at all — a "loose end". Not a
39
44
  # cell on the parity grid (nothing binds it to a requirement) and not an orphan
@@ -47,25 +52,37 @@ module Fp
47
52
  # know *which* test the marker sits above (matched by line or by test name) or only
48
53
  # that the marker is somewhere in the same file — a title is only worth reporting in
49
54
  # the first case.
50
- Match = Struct.new(:testcase, :marker, :precise, keyword_init: true)
55
+ #
56
+ # `repo_file` is the testcase's source file expressed relative to the *repository
57
+ # root*, which is what gets reported. The path in the report is relative to whatever
58
+ # directory the runner was invoked from (vitest reports `src/pages/Foo.test.jsx` from
59
+ # inside `frontend/`), so reporting it verbatim produces a link to a file that doesn't
60
+ # exist at that path in the repo.
61
+ Match = Struct.new(:testcase, :marker, :precise, :repo_file, keyword_init: true)
51
62
 
52
63
  # Evidence discovered by binding a testcase to a marker.
53
64
  # `line` is the 1-based line number of the test in its source file: the
54
65
  # testcase's own line when the runner reported one, otherwise the line of
55
66
  # the fp:<slug> marker that annotates it (markers sit directly above the
56
67
  # test, so the marker line is a close stand-in when the runner omits lines).
57
- Binding = Struct.new(:slug, :surface, :file, :line, :title, :state, keyword_init: true)
68
+ Binding = Struct.new(:slug, :surface, :test_type, :file, :line, :title, :state, keyword_init: true)
58
69
 
59
70
  # Matches `fp:<slug>` inside a comment, with an optional `@surface` (or
60
- # `@surface1,surface2`) suffix to target one or more specific surfaces:
71
+ # `@surface1,surface2`) suffix to target one or more specific surfaces, and an
72
+ # optional `:<type>` suffix to pin the kind of test:
61
73
  #
62
- # fp:print_qr # uses the --surface flag as the default
74
+ # fp:print_qr # --surface flag default, unspecified type
63
75
  # fp:print_qr@api # reports evidence for the `api` surface
64
76
  # fp:print_qr@api,web # reports for both `api` and `web`
77
+ # fp:print_qr@api:e2e # the `api` surface, filed as an e2e test
78
+ # fp:print_qr:unit # --surface default, filed as a unit test
79
+ # fp:print_qr@api,web:e2e # both surfaces, each filed as an e2e test
65
80
  #
66
- # Slugs and surfaces are lowercase letters, digits, underscores and hyphens
67
- # (matching the propose/report convention).
68
- MARKER_RE = /fp:([a-z0-9][a-z0-9_-]*)(?:@([a-z0-9_-]+(?:,[a-z0-9_-]+)*))?/.freeze
81
+ # The type pins the kind of test (unit, integration, e2e, manual, other) so a
82
+ # requirement that demands several kinds can tell them apart. It applies to every
83
+ # surface the marker names. Slugs, surfaces and types are lowercase letters, digits,
84
+ # underscores and hyphens (matching the propose/report convention).
85
+ MARKER_RE = /fp:([a-z0-9][a-z0-9_-]*)(?:@([a-z0-9_-]+(?:,[a-z0-9_-]+)*))?(?::([a-z0-9_-]+))?/.freeze
69
86
 
70
87
  # A marker only counts when it is a comment on its own line — the documented
71
88
  # convention ("the fp:<slug> comment sits immediately above its test"). Requiring a
@@ -174,7 +191,7 @@ module Fp
174
191
  # unmatched: [TestCase, ...], # testcases with no discoverable marker
175
192
  # missing_surface: [Binding-ish], # markers with no surface and no default
176
193
  # }
177
- def bind_markers(testcases, base_dir: Dir.pwd, default_surface: nil)
194
+ def bind_markers(testcases, base_dir: Dir.pwd, default_surface: nil, default_test_type: nil)
178
195
  bindings = {}
179
196
  matched = {}
180
197
  missing_surface = []
@@ -187,11 +204,17 @@ module Fp
187
204
  surfaces = marker[:surfaces]
188
205
  surfaces = [default_surface] if surfaces.empty?
189
206
 
207
+ # The marker's own type pin wins; otherwise the --test-type default (nil → the
208
+ # server files it as `unspecified`).
209
+ test_type = marker[:test_type] || default_test_type
210
+
190
211
  surfaces.each do |surface|
191
212
  if surface.nil? || surface.empty?
192
213
  missing_surface << { slug: marker[:slug], file: tc.file }
193
214
  else
194
- record_binding(bindings, marker[:slug], surface, tc, marker, titles_reliable: match.precise)
215
+ record_binding(bindings, marker[:slug], surface, tc, marker,
216
+ titles_reliable: match.precise, file: match.repo_file,
217
+ test_type: test_type)
195
218
  end
196
219
  end
197
220
  end
@@ -212,7 +235,7 @@ module Fp
212
235
  # it was skipped (a skipped test is not proof of anything), and `passing` otherwise.
213
236
  #
214
237
  # Returns { results: [CiResult, ...], unmatched: [TestCase], missing_surface: [...] }.
215
- def ci_results(testcases, base_dir: Dir.pwd, default_surface: nil)
238
+ def ci_results(testcases, base_dir: Dir.pwd, default_surface: nil, default_test_type: nil)
216
239
  aggregates = {}
217
240
  matched = {}
218
241
  missing_surface = []
@@ -225,31 +248,47 @@ module Fp
225
248
  surfaces = marker[:surfaces]
226
249
  surfaces = [default_surface] if surfaces.empty?
227
250
 
251
+ # The marker's own type pin wins; otherwise the --test-type default (nil → the
252
+ # server files it as `unspecified`). One CI verdict per (slug, surface, type).
253
+ test_type = marker[:test_type] || default_test_type
254
+
228
255
  surfaces.each do |surface|
229
256
  if surface.nil? || surface.empty?
230
257
  missing_surface << { slug: marker[:slug], file: tc.file }
231
258
  next
232
259
  end
233
260
 
234
- key = [marker[:slug], surface]
235
- agg = aggregates[key] ||= { file: tc.file, line: tc.line || marker[:line], ran: 0, failed: 0, skipped: 0 }
261
+ key = [marker[:slug], surface, test_type]
262
+ agg = aggregates[key] ||= { file: match.repo_file, line: tc.line || marker[:line],
263
+ title: nil, failing_title: nil, ran: 0, failed: 0, skipped: 0 }
236
264
  if tc.stub?
237
265
  agg[:skipped] += 1
238
266
  else
239
267
  agg[:ran] += 1
240
268
  agg[:failed] += 1 if tc.failed?
241
269
  end
270
+
271
+ # Name a test for the evidence row. The first *failing* test is the one a human
272
+ # wants to see when the cell goes red; otherwise the first that ran will do.
273
+ # Only when we know which test the marker annotates — a guess here is worse
274
+ # than no title.
275
+ if match.precise && (title = presence(tc.name))
276
+ agg[:title] ||= title
277
+ agg[:failing_title] ||= title if tc.failed?
278
+ end
242
279
  end
243
280
  end
244
281
 
245
- results = aggregates.map do |(slug, surface), agg|
282
+ results = aggregates.map do |(slug, surface, test_type), agg|
246
283
  status =
247
284
  if agg[:failed].positive? then 'failing'
248
285
  elsif agg[:ran].zero? then 'stub'
249
286
  else 'passing'
250
287
  end
251
288
 
252
- CiResult.new(slug: slug, surface: surface, status: status, file: agg[:file], line: agg[:line],
289
+ CiResult.new(slug: slug, surface: surface, test_type: test_type, status: status,
290
+ file: agg[:file], line: agg[:line],
291
+ title: agg[:failing_title] || agg[:title],
253
292
  test_count: agg[:ran], failure_count: agg[:failed])
254
293
  end
255
294
 
@@ -274,21 +313,66 @@ module Fp
274
313
  # can never disagree about which test a marker annotates.
275
314
  def walk_matches(testcases, base_dir)
276
315
  marker_cache = {}
316
+ root = repo_root_for(base_dir)
277
317
 
278
318
  # Group testcases by their source file so we can reason about ordering.
279
- testcases.group_by(&:file).each do |_file, cases|
319
+ testcases.group_by(&:file).each do |file, cases|
280
320
  markers = markers_for(cases.first, base_dir, marker_cache)
281
321
  next if markers.empty?
282
322
 
323
+ repo_file = repo_relative_path(file, base_dir, root)
324
+
283
325
  cases.each do |tc|
284
326
  marker, precise = marker_for(tc, cases, markers)
285
327
  next unless marker
286
328
 
287
- yield Match.new(testcase: tc, marker: marker, precise: precise)
329
+ yield Match.new(testcase: tc, marker: marker, precise: precise, repo_file: repo_file)
288
330
  end
289
331
  end
290
332
  end
291
333
 
334
+ # The repository root containing `dir`, or nil when `dir` isn't inside one. #
335
+ # Found by walking up for a `.git` entry rather than shelling out to git, so it also
336
+ # works in a worktree (where `.git` is a file) and costs nothing.
337
+ def repo_root_for(dir)
338
+ current = File.expand_path(dir)
339
+
340
+ loop do
341
+ return current if File.exist?(File.join(current, '.git'))
342
+
343
+ parent = File.dirname(current)
344
+ return nil if parent == current
345
+
346
+ current = parent
347
+ end
348
+ end
349
+
350
+ # Express a report's test-file path relative to the repository root.
351
+ #
352
+ # The path in a JUnit report is relative to wherever the runner ran, which is not
353
+ # necessarily the repo root — vitest invoked in `frontend/` reports
354
+ # `src/pages/Matrix.test.jsx`, and `--base-dir frontend` is what makes that resolvable
355
+ # locally. Evidence is rendered as a GitHub link built from the repo plus this path,
356
+ # so reporting it verbatim links to a path that doesn't exist (a 404).
357
+ #
358
+ # Falls back to the path as given when there's no repo to relativise against, or when
359
+ # the file resolves outside it.
360
+ def repo_relative_path(file, base_dir, root = repo_root_for(base_dir))
361
+ return file if file.nil? || file.empty? || root.nil?
362
+
363
+ absolute = File.expand_path(file, base_dir)
364
+ prefix = "#{root}#{File::SEPARATOR}"
365
+ return file unless absolute.start_with?(prefix)
366
+
367
+ absolute.delete_prefix(prefix)
368
+ end
369
+
370
+ # nil for blank, the stripped value otherwise.
371
+ def presence(value)
372
+ stripped = value.to_s.strip
373
+ stripped.empty? ? nil : stripped
374
+ end
375
+
292
376
  # Determine the marker (slug + surfaces) that annotates a testcase.
293
377
  # Returns [marker, precise] — or [nil, false] when nothing can be tied to it.
294
378
  def marker_for(testcase, sibling_cases, markers)
@@ -363,9 +447,12 @@ module Fp
363
447
  candidate
364
448
  end
365
449
 
366
- def record_binding(bindings, slug, surface, testcase, marker, titles_reliable: true)
367
- rel_file = testcase.file
368
- key = [slug, surface, rel_file]
450
+ def record_binding(bindings, slug, surface, testcase, marker, titles_reliable: true, file: nil,
451
+ test_type: nil)
452
+ rel_file = file || testcase.file
453
+ # test_type is part of the key: a unit test and an e2e test for the same
454
+ # slug/surface/file are distinct evidence rows, so they mustn't collapse into one.
455
+ key = [slug, surface, test_type, rel_file]
369
456
  state = testcase.stub? ? 'stub' : 'present'
370
457
 
371
458
  # The test's line: prefer the runner-reported testcase line; fall back to
@@ -377,8 +464,8 @@ module Fp
377
464
  # Prefer a concrete 'present' state over 'stub' when a slug has both.
378
465
  return if existing && !(existing.state == 'stub' && state == 'present')
379
466
 
380
- bindings[key] = Binding.new(slug: slug, surface: surface, file: rel_file, line: line,
381
- title: (testcase.name if titles_reliable), state: state)
467
+ bindings[key] = Binding.new(slug: slug, surface: surface, test_type: test_type, file: rel_file,
468
+ line: line, title: (testcase.name if titles_reliable), state: state)
382
469
  end
383
470
 
384
471
  # Load and cache the fp:<slug> markers (with line numbers) for a testcase's
@@ -400,9 +487,10 @@ module Fp
400
487
  cache[file] = markers
401
488
  end
402
489
 
403
- # Scan file contents for fp:<slug>[@surface[,surface...]] markers, returning
404
- # [{ slug:, surfaces: [..], line:, title:, title_kind: }, ...] (1-based line numbers).
405
- # `surfaces` is [] when the marker pins none (use the default surface).
490
+ # Scan file contents for fp:<slug>[@surface[,surface...]][:type] markers, returning
491
+ # [{ slug:, surfaces: [..], test_type:, line:, title:, title_kind: }, ...]
492
+ # (1-based line numbers). `surfaces` is [] when the marker pins none (use the default
493
+ # surface). `test_type` is nil when the marker pins none (filed as `unspecified`).
406
494
  #
407
495
  # `title` is the name of the test (or describe block) declared below the marker, used
408
496
  # to bind markers to results from runners that report no line numbers. It is nil when
@@ -415,10 +503,11 @@ module Fp
415
503
  lines.each_with_index do |line, idx|
416
504
  next unless line.match?(COMMENT_LINE_RE)
417
505
 
418
- line.scan(MARKER_RE) do |(slug, surface_list)|
506
+ line.scan(MARKER_RE) do |(slug, surface_list, test_type)|
419
507
  surfaces = surface_list ? surface_list.split(',').map(&:strip).reject(&:empty?) : []
420
508
  title, kind = declared_test_after(lines, idx)
421
- markers << { slug: slug, surfaces: surfaces, line: idx + 1, title: title, title_kind: kind }
509
+ markers << { slug: slug, surfaces: surfaces, test_type: presence(test_type),
510
+ line: idx + 1, title: title, title_kind: kind }
422
511
  end
423
512
  end
424
513
  markers
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.13'
4
+ VERSION = '0.0.15'
5
5
  end
data/skills/fp/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: fp
3
- description: "Use the fp CLI to track feature parity: report evidence linking tests to requirements, propose new requirements as drafts, view the parity matrix, and upload test results from JUnit XML when CI isn't available."
3
+ description: "Use the fp CLI as a complete product specification: write decision-ready requirements, inventory and decompose scope, manage changes through review, bind tests to focused promises, report evidence, and inspect parity."
4
4
  license: MIT
5
5
  compatibility: Requires the fp executable on PATH (gem install featureparity)
6
6
  metadata:
@@ -41,17 +41,50 @@ If not set, ask the user for their API key or have them run `fp setup`.
41
41
 
42
42
  ## Core workflows
43
43
 
44
- ### 1. Check existing requirements
44
+ ### 1. Inventory and model the complete requirement scope
45
45
 
46
46
  ```bash
47
47
  fp list --project stowzilla # All active requirements
48
+ fp list --project stowzilla --status draft # Proposed work; avoid duplicates
48
49
  fp list --project stowzilla --gaps # Requirements with a required surface lacking evidence
49
- fp show <slug> --project stowzilla # Requirement details
50
+ fp show <slug> --project stowzilla # Requirement details and evidence
51
+ fp history <slug> --project stowzilla # Origins and pending changes
50
52
  fp matrix --project stowzilla # ASCII parity matrix
51
53
  fp matrix --project stowzilla --csv # Export as CSV
52
54
  fp check --project stowzilla # Enforce parity (exits 1 on violations)
53
55
  ```
54
56
 
57
+ Treat FeatureParity as the human-readable product specification, not a bookkeeping step.
58
+ Before coding, inventory the entire request: user-visible behaviours, policies, limits,
59
+ plans, deadlines, state transitions, permissions, failure modes, and affected surfaces.
60
+ Compare all of it with active and draft requirements, including adjacent requirements the
61
+ change might make inaccurate.
62
+
63
+ Model that scope deliberately:
64
+
65
+ - One independently testable or independently failable product promise per focused
66
+ requirement. Split an acceptance sentence when its clauses could pass or fail separately.
67
+ - Use a parent for a broad capability and children for distinct behaviours, policies, or
68
+ surface-specific promises. Do not model implementation details with no product promise.
69
+ - Record concrete decisions such as prices, quotas, dates, roles, and fallbacks in acceptance
70
+ criteria rather than leaving them as assumptions in code or a pull request.
71
+ - Propose changes to existing requirements whose meaning moved; adding a new requirement is
72
+ not enough if it leaves an old one false.
73
+ - Reconcile again before finishing: every shipped behaviour maps to a current requirement,
74
+ every changed promise is recorded, and every focused requirement has marked evidence or
75
+ remains an explicit gap. Never let one broad marker imply coverage of untested clauses.
76
+
77
+ **Prevent contradictions before they enter the catalog.** Before proposing a new or changed
78
+ promise, compare its intended acceptance criteria with related active and draft requirements
79
+ and read their history. Ask whether both promises can be true at the same time for the same
80
+ user, state, and conditions. If not, do not add another requirement.
81
+
82
+ A failing test bound to an existing requirement, or an implementation that only works by
83
+ changing one, is a contradiction signal — not permission to weaken the test or write a second
84
+ requirement around it. Identify which promise matches product intent, then use
85
+ `fp propose-change` (or `--removal`) with the source and reason to put that decision before a
86
+ human. If the intent is unclear, stop and ask rather than making the catalog say both things.
87
+
55
88
  ### 2. Add the fp:<slug> marker to tests
56
89
 
57
90
  Place a comment immediately before the test to bind it to a requirement:
@@ -106,31 +139,82 @@ fp report --junit junit.xml \
106
139
  - `--surface` is the default; markers can override with `@suffix`
107
140
  - `--base-dir DIR` sets where relative paths resolve from
108
141
 
109
- ### 5. Propose new requirements
142
+ ### 5. Write and propose decision-ready requirements
143
+
144
+ A requirement is a product decision a reviewer can approve, not a design task for an
145
+ engineer to carry out. It should sound like a capable teammate wrote it: natural, direct,
146
+ and specific enough to protect an outcome without dictating an accidental solution.
147
+
148
+ Treat the catalog as the current record of shared understanding, not a finished specification
149
+ handed from a product owner to implementers. Requirements are discovered and refined through
150
+ ongoing conversation among customers, domain experts, and builders. The human product owner
151
+ makes the final value and priority decisions; agents participate by exposing gaps,
152
+ inconsistencies, alternatives, and useful splits. Prefer customer outcomes over devotion to a
153
+ feature list.
154
+
155
+ Before drafting:
156
+
157
+ - Learn from the request, related requirements and history, support reports, research, and
158
+ observed user behaviour when available. Look for what people attempt, where they abandon a
159
+ task, what they actually use, and what experiments reveal. That evidence may expose a
160
+ different need from the first solution they suggested.
161
+ - Separate facts and decided policy from open questions. Never turn a missing decision into
162
+ a plausible-sounding price, quota, timeout, count, date, role, or fallback. Ask when the
163
+ answer changes the product promise.
164
+ - Identify the actor, the situation that triggers the need, the valuable outcome, and material
165
+ boundaries such as permissions, failures, and affected surfaces.
166
+ - Keep one independently failable promise per requirement. Use a parent and focused children
167
+ when a capability contains several decisions.
168
+
169
+ Write each field for a reviewer, not for the implementation:
170
+
171
+ - **Title:** one clear product promise in present tense. Name the capability or outcome, not
172
+ the project (`Dispatchers see current shipment status`, not `Implement status polling`).
173
+ - **Why:** one or two natural sentences saying who needs this, the problem or risk, and the
174
+ outcome worth protecting. Do not repeat the title, advertise the feature, or explain how
175
+ it will be built. Delete AI filler such as “ensure a seamless, robust experience.”
176
+ - **Acceptance:** observable behaviour that lets someone decide whether the promise is true.
177
+ State the relevant trigger or situation, the result, and material boundaries. Include
178
+ failure behaviour, permissions, or cross-surface consistency when those affect the promise.
179
+ - **Examples:** use concrete examples or Given/When/Then cases when they clarify a rule or an
180
+ edge. Examples and executable tests ground and double-check the specification; they do not
181
+ define every case or replace the general rule and continued conversation.
182
+ - **Numbers and policy:** include a number only when the source made it a real product decision
183
+ or an external constraint requires it. Do not invent implementation defaults and present
184
+ them as scope.
185
+ - **Implementation detail:** omit components, data models, algorithms, endpoints, polling
186
+ intervals, and UI controls unless that interface or constraint is itself user-visible policy.
187
+
188
+ A good requirement is concise, not merely short. Keep every sentence that changes a decision;
189
+ remove everything that only sounds thorough.
190
+
191
+ - ✅ **Title:** `Dispatchers see current shipment status`
192
+ **Why:** `Dispatchers need to spot delayed shipments before they miss a delivery window.`
193
+ **Acceptance:** `When a shipment's status changes, the dashboard shows the new status; if status is unavailable, it says so instead of presenting stale data as current.`
194
+ - ❌ **Title:** `Implement Redis-backed 30-second status polling`
195
+ **Why:** `This important feature ensures a seamless, robust experience.`
196
+ **Acceptance:** `Use a background worker and refresh every 30 seconds.`
197
+ This invents policy, prescribes a design, and never states the outcome.
198
+
199
+ Before saving, read the draft as a manager deciding scope: Is the value clear? Can the result
200
+ be observed? Are real product decisions distinguished from guesses? Could every clause fail
201
+ independently and, if so, should it be split? Does every sentence earn its place? Revise or ask
202
+ before creating the record if the answer is no.
203
+
204
+ Then create the proposal as a draft:
110
205
 
111
206
  ```bash
112
207
  fp propose --project stowzilla \
113
- --slug print_container_qr \
114
- --name "Print QR on container label" \
115
- --why "Staff scan the label to find a container; without a QR they key the ID by hand and mis-type it" \
116
- --required api,customer_android \
117
- --acceptance "QR scans to the container's detail page"
208
+ --slug current_shipment_status \
209
+ --name "Dispatchers see current shipment status" \
210
+ --why "Dispatchers need to spot delayed shipments before they miss a delivery window" \
211
+ --required api,web \
212
+ --acceptance "When a shipment's status changes, the dashboard shows the new status; if status is unavailable, it says so instead of presenting stale data as current" \
213
+ --source-type fizzy \
214
+ --source-url https://app.fizzy.do/123/cards/456
118
215
  ```
119
216
 
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
-
133
- **Agents always create requirements as draft.** A human must activate in the web app.
217
+ **Agents always create requirements as draft.** A human must activate them in the web app.
134
218
 
135
219
  ### 6. Propose a change to an existing requirement
136
220
 
@@ -158,8 +242,9 @@ Rules:
158
242
 
159
243
  - Always pass `--reason` and, where one exists, `--source-type`/`--source-url`. The whole
160
244
  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.
245
+ - Agents may correct `--name` (the title) or `--slug` through a proposal. Existing
246
+ evidence follows a proposed slug, but every `fp:<slug>` marker still needs the same
247
+ correction. Agents still cannot remove a required surface.
163
248
  - Agents cannot approve or reject. Only humans review.
164
249
  - One open proposal per requirement — review the current one before proposing another.
165
250
 
@@ -130,36 +130,75 @@ In JUnit mode:
130
130
 
131
131
  ### fp propose
132
132
 
133
- Propose a new requirement as draft.
133
+ Propose a new requirement as a draft. Before proposing, inventory the full request and compare
134
+ it with both active and draft requirements. The catalog should tell a reviewer what the product
135
+ promises, not preserve the first implementation idea somebody mentioned.
136
+
137
+ Use one focused requirement per independently testable or failable product promise. Use a
138
+ parent plus children for a broad capability with distinct behaviours, policies, or
139
+ surface-specific promises. If the work makes an existing requirement inaccurate, use
140
+ `fp propose-change` as well as adding anything new.
141
+
142
+ Before adding or changing a requirement, compare its acceptance criteria with related active
143
+ and draft requirements and their history. Ask whether both promises can be true for the same
144
+ user, state, and conditions. A failing bound test or an implementation that requires changing
145
+ an existing promise is a contradiction signal: decide which promise reflects product intent,
146
+ then record that decision with `fp propose-change` or `--removal`. Do not weaken the test or add
147
+ a second requirement around the conflict; ask a human when product intent is unclear.
134
148
 
135
149
  ```bash
136
150
  fp propose --project stowzilla \
137
- --slug print_container_qr \
138
- --name "Print QR on container label" \
139
- --why "Staff scan the label to find a container; without a QR they key the ID by hand and mis-type it" \
140
- --required api,customer_android \
141
- --acceptance "QR scans to the container's detail page"
151
+ --slug current_shipment_status \
152
+ --name "Dispatchers see current shipment status" \
153
+ --why "Dispatchers need to spot delayed shipments before they miss a delivery window" \
154
+ --required api,web \
155
+ --acceptance "When a shipment's status changes, the dashboard shows the new status; if status is unavailable, it says so instead of presenting stale data as current" \
156
+ --source-type fizzy \
157
+ --source-url https://app.fizzy.do/123/cards/456
142
158
  ```
143
159
 
144
160
  | Flag | Required | Description |
145
161
  |------|----------|-------------|
146
162
  | `--project` | Yes | Project slug |
147
- | `--slug` | Yes | Requirement slug (immutable, choose carefully) |
148
- | `--name` | Yes | Human-readable name |
149
- | `--why` | No | One plain sentence: the user need or outcome the feature serves (see below) |
163
+ | `--slug` | Yes | Initial requirement key (later corrections use `propose-change`) |
164
+ | `--name` | Yes | Short product promise in present tense |
165
+ | `--why` | No | User need, problem or risk, and valuable outcome |
150
166
  | `--required` | No | Comma-separated surfaces that must implement this |
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)
161
-
162
- **Agents always create requirements as draft.** A human must activate in the web app.
167
+ | `--acceptance` | No | Observable behaviour and material boundaries |
168
+
169
+ **Write for a decision-maker, not an implementer.** The result should sound natural and be
170
+ ready for management review without someone translating AI boilerplate or reverse-engineering
171
+ the intended outcome. Treat it as the current record of an ongoing conversation, not a finished
172
+ decree from product to engineering. Humans decide value and priority; agents surface gaps,
173
+ inconsistencies, alternatives, and useful splits. Prefer outcomes over feature lists.
174
+
175
+ - **Title:** name the capability or outcome, not the work (`Dispatchers see current shipment
176
+ status`, not `Implement status polling`).
177
+ - **Why:** use one or two natural sentences to say who needs this, the problem or risk, and the
178
+ outcome worth protecting. Do not repeat the title, sell the feature, explain the mechanism,
179
+ or pad it with phrases such as “ensure a seamless, robust experience.”
180
+ - **Acceptance:** state the relevant trigger or situation, the observable result, and material
181
+ boundaries such as permissions, unavailable states, and affected surfaces. Concrete examples
182
+ and tests ground and double-check the rule; they do not define every case or replace the
183
+ general rule and continued conversation.
184
+ - **Numbers and policy:** include prices, quotas, timeouts, counts, dates, roles, and fallbacks
185
+ only when the source made them real product decisions or external constraints. Never invent a
186
+ plausible value to fill silence; ask when the answer changes the promise.
187
+ - **Implementation detail:** omit components, data models, algorithms, endpoints, polling
188
+ intervals, and UI controls unless the interface or constraint is itself part of the promise.
189
+
190
+ Learn from observed user behaviour and other evidence when available: what people attempt,
191
+ where they abandon a task, what they actually use, and what experiments reveal. The first
192
+ proposed solution may point at the need without defining the right scope. Preserve meaningful
193
+ uncertainty as a question instead of laundering a guess into acceptance criteria. Concise does
194
+ not mean vague: keep every sentence that changes the decision and cut everything that only
195
+ sounds thorough.
196
+
197
+ Before saving, ask: Is the value clear? Can the result be observed? Are decided policies
198
+ separated from guesses? Could clauses fail independently and need splitting? Does every sentence
199
+ earn its place?
200
+
201
+ **Agents always create requirements as draft.** A human must activate them in the web app.
163
202
 
164
203
  ### fp propose-change
165
204
 
@@ -191,7 +230,7 @@ fp propose-change print_container_qr --project stowzilla \
191
230
  | `--required` | No | Proposed surfaces (comma-separated; agents cannot remove existing ones) |
192
231
  | `--category` | No | Proposed category |
193
232
  | `--parent` | No | Proposed parent requirement, by slug |
194
- | `--slug` | No | Proposed new slug — **humans only**; breaks existing `fp:<slug>` markers |
233
+ | `--slug` | No | Proposed new key; evidence follows it, but existing `fp:<slug>` markers must be updated |
195
234
  | `--reason` | No | Why the change is needed |
196
235
  | `--source-type` | No | `fizzy`, `discord`, `github_issue`, `jira`, `notion`, `manual`, `ai_chat` |
197
236
  | `--source-url` | No | Link to the card/message/issue that prompted it |
@@ -204,7 +243,7 @@ Refusals, all printed verbatim:
204
243
 
205
244
  | Situation | Status |
206
245
  |-----------|--------|
207
- | Agent tries to change `--slug` | 403 |
246
+ | Proposed key belongs to another requirement | 422 |
208
247
  | Agent tries to remove a required surface | 403 |
209
248
  | Proposed values match the current text | 422 |
210
249
  | Requirement already has a proposal awaiting review | 409 |
@@ -26,6 +26,31 @@ Examples:
26
26
 
27
27
  When using `fp report --junit`, each pinned surface produces a separate evidence upload.
28
28
 
29
+ ## Test-type pinning
30
+
31
+ A requirement can demand several *kinds* of test (a unit test and an end-to-end test,
32
+ say). Pin a marker's kind with a `:type` suffix, after the surfaces if any:
33
+
34
+ ```
35
+ fp:<slug>:<type>
36
+ fp:<slug>@<surface>:<type>
37
+ fp:<slug>@<surface1>,<surface2>:<type>
38
+ ```
39
+
40
+ Valid types: `unit`, `integration`, `e2e`, `manual`, `other`.
41
+
42
+ Examples:
43
+ - `fp:print_qr` — unspecified type (and the `--surface` default)
44
+ - `fp:print_qr:unit` — filed as a unit test
45
+ - `fp:print_qr@api:e2e` — the `api` surface, filed as an e2e test
46
+ - `fp:print_qr@api,web:e2e` — both surfaces, each filed as an e2e test
47
+
48
+ A marker that pins no type takes the `--test-type` flag's value (if given), otherwise
49
+ `unspecified`. A requirement declares which kinds it needs with
50
+ `fp propose --required-types unit,e2e`; a surface is covered only once every demanded
51
+ kind has evidence. A requirement naming no types inherits the project default, and when
52
+ neither is set any one test satisfies a surface.
53
+
29
54
  ## Language examples
30
55
 
31
56
  ### Ruby/RSpec
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: featureparity
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.13
4
+ version: 0.0.15
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-21 00:00:00.000000000 Z
11
+ date: 2026-10-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: net-http
@@ -94,10 +94,13 @@ files:
94
94
  - lib/fp/cli.rb
95
95
  - lib/fp/client.rb
96
96
  - lib/fp/commands.rb
97
+ - lib/fp/commands/attach.rb
98
+ - lib/fp/commands/attachments.rb
97
99
  - lib/fp/commands/base.rb
98
100
  - lib/fp/commands/check.rb
99
101
  - lib/fp/commands/ci_report.rb
100
102
  - lib/fp/commands/config_cmd.rb
103
+ - lib/fp/commands/conversation.rb
101
104
  - lib/fp/commands/export.rb
102
105
  - lib/fp/commands/help.rb
103
106
  - lib/fp/commands/history.rb