featureparity 0.0.7 → 0.0.9

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d668266132dc93de82fc60d7603f340ab05a8e5852fa4cde36ce9e6e4586ff18
4
- data.tar.gz: 7d1b2691eeade5eabf110c518dca40808ce1beee2a0809b422376eaf914692c4
3
+ metadata.gz: 20c34d118adff460a3de1eaaa15f66718593100ae0261f9dc064f63b65d8b3f2
4
+ data.tar.gz: e14ab1bf7bbf6c646f42e0b9a9291653e072f47055b8dff8ccf2294d10effa7a
5
5
  SHA512:
6
- metadata.gz: dafb288e14865ffd01b3cf739a510b8c7c2cf103b5e0867b07ce8f79264a14e8ab34ef95a01a73a77c3566adc2ce792488a3581f8ca184f1cd9b0678f8a4bce2
7
- data.tar.gz: eee75e4d691fbd6884b0d417a1e8a9ae8c88e15da95ad1919b64f9a7e6a1ed915a5a8baa2687603cbe9d41a3f27738d4b0ae360d3ec97efb0885c45e62b4bcaf
6
+ metadata.gz: 248bf8d80901ed3a4075e44e501169bd37803dc4fad78b3b342cfd79307a4649b824664a0b7ce5013f3fd323b2f64ed7deaa2318813389604e21d8c212cd2c36
7
+ data.tar.gz: 1e22a8d5b38f77581693dd4cb0d912aedb0d09afa8cdffaa96332eca3afc1d9c62893ff2c0a09e9ca7f4d44d46fd6126d71b915cb3bfa357099c2a3726ce2006
data/lib/fp/client.rb CHANGED
@@ -27,17 +27,43 @@ module Fp
27
27
  get("/api/projects/#{project_id}/surfaces")
28
28
  end
29
29
 
30
+ # GET /projects/:id/surfaces/:surface_id
31
+ def get_surface(project_id, surface_id)
32
+ get("/api/projects/#{project_id}/surfaces/#{surface_id}")
33
+ end
34
+
35
+ # Find a single surface by its key within a project.
36
+ #
37
+ # Agents speak surface *keys* ("customer_android"), not the KSUID the surface is
38
+ # stored under, so — the same way find_requirement_by_slug resolves a slug — list the
39
+ # project's surfaces and match on key. Returns the same { ok:, data: } envelope the
40
+ # rest of the client speaks, with a 404 when no surface has that key.
41
+ def find_surface_by_key(project_id, key)
42
+ result = list_surfaces(project_id)
43
+ return result unless result[:ok]
44
+
45
+ surfaces = result[:data]['surfaces'] || []
46
+ surface = surfaces.find { |s| s['key'] == key }
47
+
48
+ if surface
49
+ { ok: true, data: { 'surface' => surface }, status: 200 }
50
+ else
51
+ { ok: false, error: "Surface '#{key}' not found in project", status: 404 }
52
+ end
53
+ end
54
+
30
55
  # POST /projects/:id/surfaces
31
56
  def create_surface(project_id, params)
32
57
  post("/api/projects/#{project_id}/surfaces", params)
33
58
  end
34
59
 
35
60
  # GET /requirements
36
- def list_requirements(project_id: nil, status: nil, category: nil)
61
+ def list_requirements(project_id: nil, status: nil, category: nil, tag: nil)
37
62
  params = {}
38
63
  params[:project_id] = project_id if project_id
39
64
  params[:status] = status if status
40
65
  params[:category] = category if category
66
+ params[:tag] = tag if tag
41
67
  get('/api/requirements', params)
42
68
  end
43
69
 
@@ -118,12 +144,16 @@ module Fp
118
144
  # writes passing/failing evidence per surface. It also marks previously
119
145
  # passing slugs that have vanished from the results as failing. This is the
120
146
  # half of the loop only CI is allowed to close (#1222).
121
- def ingest_ci_results(project_id, ci_url:, surface:, junit_xml:)
122
- post("/api/projects/#{project_id}/ci_results", {
123
- ci_url: ci_url,
124
- surface: surface,
125
- junit_xml: junit_xml
126
- })
147
+ def ingest_ci_results(project_id, ci_url:, surface:, junit_xml:, pr_url: nil)
148
+ body = {
149
+ ci_url: ci_url,
150
+ surface: surface,
151
+ junit_xml: junit_xml
152
+ }
153
+ # Only send pr_url when set, so a merged-branch run stays a plain ingest.
154
+ body[:pr_url] = pr_url if pr_url && !pr_url.empty?
155
+
156
+ post("/api/projects/#{project_id}/ci_results", body)
127
157
  end
128
158
 
129
159
  # GET /projects/:project_id/parity — the computed parity grid and gate verdict.
@@ -25,11 +25,18 @@ module Fp
25
25
  # Multiple --junit flags may be passed to ingest several reports for the same
26
26
  # surface in one invocation (e.g. a suite split across shards). Each is
27
27
  # uploaded in turn.
28
+ #
29
+ # --pr <url> marks the run as a pull request's CI rather than the merged branch's.
30
+ # Passing tests are then recorded as `pr_passing` ("would pass once merged") instead
31
+ # of `passing`, and no merged evidence is demoted. Run it without --pr on pushes to
32
+ # the default branch to record the real `passing`, superseding any pr_passing a prior
33
+ # PR run left behind.
28
34
  class CiReport < Base
29
35
  KNOWN_FLAGS = {
30
36
  project: :string,
31
37
  surface: :string,
32
38
  ci_url: :string,
39
+ pr: :string,
33
40
  junit: :string
34
41
  }.freeze
35
42
 
@@ -64,7 +71,8 @@ module Fp
64
71
  project_id,
65
72
  ci_url: opts[:ci_url],
66
73
  surface: opts[:surface],
67
- junit_xml: xml
74
+ junit_xml: xml,
75
+ pr_url: opts[:pr]
68
76
  )
69
77
 
70
78
  unless result[:ok]
@@ -92,6 +100,7 @@ module Fp
92
100
  project: opts[:project],
93
101
  surface: opts[:surface],
94
102
  ci_url: opts[:ci_url],
103
+ pr_url: opts[:pr],
95
104
  evidence_created: totals[:created],
96
105
  evidence_updated: totals[:updated],
97
106
  missing_marked_failing: totals[:missing_failing],
@@ -99,11 +108,11 @@ module Fp
99
108
  }
100
109
 
101
110
  output.success(data) do
102
- puts "Ingested CI results for surface '#{opts[:surface]}':"
111
+ mode = opts[:pr] ? " (PR mode — passing tests recorded as pr_passing)" : ''
112
+ puts "Ingested CI results for surface '#{opts[:surface]}'#{mode}:"
103
113
  totals[:reports].each do |report|
104
114
  report[:results].each do |r|
105
- glyph = r['status'] == 'passing' ? '✓' : (r['status'] == 'failing' ? '✗' : '•')
106
- puts " #{glyph} #{r['slug']} (#{r['status']}) #{r['source_file']}"
115
+ puts " #{Parity.glyph(r['status'])} #{r['slug']} (#{r['status']}) #{r['source_file']}"
107
116
  end
108
117
  end
109
118
  puts
@@ -15,6 +15,7 @@ module Fp
15
15
  init --project <slug> Onboard this repo: config, CI gate, agent docs
16
16
  projects List projects you have access to
17
17
  surfaces --project <slug> List surfaces for a project
18
+ surfaces show <key> --project ... View a single surface's details
18
19
  surfaces add <key> --project ... Create a surface
19
20
  list --project <slug> List active requirements
20
21
  show <slug> --project <slug> Show requirement details
@@ -25,6 +26,7 @@ module Fp
25
26
  report <slug> --project <slug>... Report evidence for a requirement
26
27
  report --junit <file> ... Upload evidence from a JUnit XML report (no CI)
27
28
  ci-report --junit <file> ... Ingest CI test results (passing/failing) — CI only
29
+ ci-report --junit <file> --pr URL Ingest a PR's CI results as pr_passing — CI only
28
30
  check Enforce parity; exits non-zero on violations
29
31
  matrix --project <slug> Show the parity matrix
30
32
  export --project <slug> Export all requirements as a Markdown document
@@ -61,6 +63,14 @@ module Fp
61
63
  at once. `--level` overrides it for one run; `--warn-only` reports violations but
62
64
  still exits 0, for adopting the gate on a repo that isn't clean yet.
63
65
 
66
+ PR-VERIFIED EVIDENCE
67
+ `ci-report --pr <url>` marks a run as a pull request's CI. Passing tests are then
68
+ recorded as `pr_passing` ("would pass once merged") instead of `passing`, and no
69
+ merged evidence is demoted. It ranks between `present` and `passing`: it counts as
70
+ covered at the `covered` level, but only `strict` accepts it — strict wants the
71
+ merged-green truth. Run ci-report *without* --pr on a push to the default branch to
72
+ record the real `passing`, which supersedes any pr_passing a prior PR left behind.
73
+
64
74
  AUTHENTICATION
65
75
  Set FP_API_KEY environment variable, or use a profile:
66
76
 
@@ -112,6 +122,9 @@ module Fp
112
122
  # List the surfaces a project tracks (requirements may only name these)
113
123
  fp surfaces --project stowzilla
114
124
 
125
+ # View a single surface's details (owner, repo, kind, test globs)
126
+ fp surfaces show customer_android --project stowzilla
127
+
115
128
  # Create a surface (agents can do this — no need to wait on the web app)
116
129
  fp surfaces add customer_android --project stowzilla \\
117
130
  --name "Customer Android App" \\
@@ -124,6 +137,9 @@ module Fp
124
137
  # List requirements filtered by category
125
138
  fp list --project stowzilla --category security
126
139
 
140
+ # List requirements filtered by tag (free-form label)
141
+ fp list --project stowzilla --tag mobile
142
+
127
143
  # Show gaps: requirements with a required surface that has no real evidence
128
144
  # (missing, stubbed, or failing). Unlike `fp list`, this reads evidence.
129
145
  fp list --project stowzilla --gaps --surface customer_android
@@ -132,13 +148,23 @@ module Fp
132
148
  fp show print_container_qr --project stowzilla
133
149
 
134
150
  # Propose a new requirement (creates as draft)
151
+ #
152
+ # --why must be brief and actually explain why: whoever approves it should grasp
153
+ # in one read the user need or outcome the feature serves. Longer is fine when it
154
+ # earns its length; wordy is not. Avoid filler, and avoid describing HOW it works
155
+ # instead of WHY anyone wants it.
156
+ # good: "Staff scan the label to find a container; without a QR they key the ID by hand and mis-type it."
157
+ # bad: "Ensures a high-quality experience across all surfaces." (filler)
158
+ # --acceptance is the observable condition that proves it's done, not a
159
+ # restatement of the title.
135
160
  fp propose --project stowzilla \\
136
161
  --slug print_container_qr \\
137
162
  --name "Print QR code on container label" \\
138
- --why "Customers scan to track containers" \\
163
+ --why "Staff scan the label to find a container; without a QR they key the ID by hand and mis-type it" \\
139
164
  --required api,customer_android,customer_ios \\
140
- --acceptance "QR code is printed and scannable" \\
141
- --category functional
165
+ --acceptance "QR scans to the container's detail page" \\
166
+ --category functional \\
167
+ --tags mobile,labeling,q3
142
168
 
143
169
  # Nest a requirement under a parent (--parent takes the parent's SLUG).
144
170
  # A child's --required surfaces must be a subset of its parent's.
@@ -309,6 +309,19 @@ module Fp
309
309
  --source-type fizzy --source-url <card-or-issue-url>
310
310
  ```
311
311
 
312
+ **Writing the `--why`: brief, and it actually explains why.** Someone approving
313
+ this requirement needs to grasp in one read why the product needs it — the user
314
+ problem it solves or the outcome it protects. State that and stop. Longer is fine
315
+ when it earns its length; wordy is not. Avoid two things: filler that says nothing
316
+ specific, and describing *how* the feature works instead of *why* anyone wants it.
317
+
318
+ - ✅ `--why "Warehouse staff scan the label to look up a container; without a QR code they key in the ID by hand and mis-type it."`
319
+ - ❌ `--why "This is an important requirement that ensures a high-quality experience for our users across all surfaces."` (filler — says nothing specific)
320
+ - ❌ `--why "The label renders a QR image encoding the container ID at print time."` (how it works, not why anyone needs it)
321
+
322
+ Same rule for `--acceptance`: state the observable condition that proves it's done
323
+ (`"QR scans to the container's detail page"`), not a restatement of the title.
324
+
312
325
  ### 2. If the requirement is wrong, propose a change — don't silently edit it
313
326
 
314
327
  Shipping something that contradicts a requirement means one of the two is wrong.
@@ -9,6 +9,7 @@ module Fp
9
9
  project: :string,
10
10
  surface: :string,
11
11
  category: :string,
12
+ tag: :string,
12
13
  status: :string,
13
14
  gaps: :boolean
14
15
  }.freeze
@@ -33,7 +34,7 @@ module Fp
33
34
  private
34
35
 
35
36
  def list_requirements(project_id, opts)
36
- result = client.list_requirements(project_id: project_id, category: opts[:category])
37
+ result = client.list_requirements(project_id: project_id, category: opts[:category], tag: opts[:tag])
37
38
 
38
39
  unless result[:ok]
39
40
  output.error(result[:error], status: result[:status])
@@ -66,6 +67,7 @@ module Fp
66
67
  if requirements.empty?
67
68
  filter_desc = [status_filter]
68
69
  filter_desc << "category: #{opts[:category]}" if opts[:category]
70
+ filter_desc << "tag: #{opts[:tag]}" if opts[:tag]
69
71
  filter_desc << "surface: #{opts[:surface]}" if opts[:surface]
70
72
  puts "No requirements found (#{filter_desc.join(', ')})."
71
73
  else
@@ -79,19 +81,21 @@ module Fp
79
81
  parent_ids = parents.map { |p| p['id'] }.to_set
80
82
  orphans = requirements.select { |r| r['parent_id'] && r['parent_id'] != '' && !parent_ids.include?(r['parent_id']) }
81
83
 
82
- headers = %w[SLUG TITLE CATEGORY SURFACES STATUS]
84
+ headers = %w[SLUG TITLE CATEGORY TAGS SURFACES STATUS]
83
85
  rows = []
84
86
 
85
87
  parents.each do |r|
86
88
  surfaces = (r['required_surfaces'] || []).join(', ')
87
89
  category = r['category'] || '-'
88
- rows << [r['slug'], truncate(r['title'], 40), category, surfaces.empty? ? '-' : surfaces, status_cell(r)]
90
+ tags = (r['tags'] || []).join(', ')
91
+ rows << [r['slug'], truncate(r['title'], 40), category, tags.empty? ? '-' : tags, surfaces.empty? ? '-' : surfaces, status_cell(r)]
89
92
 
90
93
  # Add children indented
91
94
  (children_by_parent[r['id']] || []).each do |child|
92
95
  child_surfaces = (child['required_surfaces'] || []).join(', ')
93
96
  child_category = child['category'] || '-'
94
- rows << [" └ #{child['slug']}", truncate(child['title'], 36), child_category, child_surfaces.empty? ? '-' : child_surfaces, status_cell(child)]
97
+ child_tags = (child['tags'] || []).join(', ')
98
+ rows << [" └ #{child['slug']}", truncate(child['title'], 36), child_category, child_tags.empty? ? '-' : child_tags, child_surfaces.empty? ? '-' : child_surfaces, status_cell(child)]
95
99
  end
96
100
  end
97
101
 
@@ -99,7 +103,8 @@ module Fp
99
103
  orphans.each do |r|
100
104
  surfaces = (r['required_surfaces'] || []).join(', ')
101
105
  category = r['category'] || '-'
102
- rows << [r['slug'], truncate(r['title'], 40), category, surfaces.empty? ? '-' : surfaces, status_cell(r)]
106
+ tags = (r['tags'] || []).join(', ')
107
+ rows << [r['slug'], truncate(r['title'], 40), category, tags.empty? ? '-' : tags, surfaces.empty? ? '-' : surfaces, status_cell(r)]
103
108
  end
104
109
 
105
110
  output.table(headers, rows)
@@ -16,7 +16,8 @@ module Fp
16
16
  category: :string,
17
17
  source_type: :string,
18
18
  source_url: :string,
19
- source_context: :string
19
+ source_context: :string,
20
+ tags: :string
20
21
  }.freeze
21
22
 
22
23
  VALID_CATEGORIES = %w[functional non-functional ux performance security compliance].freeze
@@ -62,6 +63,12 @@ module Fp
62
63
  params[:source_url] = opts[:source_url] if opts[:source_url]
63
64
  params[:source_context] = opts[:source_context] if opts[:source_context]
64
65
 
66
+ # Tags are comma-separated. Send the raw list — the API normalizes
67
+ # (downcase/trim/dedupe) and validates.
68
+ if opts[:tags]
69
+ params[:tags] = opts[:tags].split(',').map(&:strip).reject(&:empty?)
70
+ end
71
+
65
72
  # Resolve parent slug to parent_id if provided
66
73
  if opts[:parent]
67
74
  parent_id = resolve_parent_id(project_id, opts[:parent])
@@ -85,6 +92,7 @@ module Fp
85
92
  puts " Title: #{requirement['title']}"
86
93
  puts " Status: #{requirement['status']}"
87
94
  puts " Category: #{requirement['category'] || '(none)'}"
95
+ puts " Tags: #{(requirement['tags'] || []).join(', ')}" unless (requirement['tags'] || []).empty?
88
96
  puts " Surfaces: #{(requirement['required_surfaces'] || []).join(', ')}"
89
97
  puts " Parent: #{opts[:parent] || '(none)'}" if opts[:parent]
90
98
  puts
@@ -116,6 +116,9 @@ module Fp
116
116
  if opts[:pr]
117
117
  puts " PR: #{opts[:pr]}"
118
118
  end
119
+ if opts[:sha]
120
+ puts " SHA: #{opts[:sha]}"
121
+ end
119
122
  if opts[:work_item]
120
123
  puts " Work: #{opts[:work_item]}"
121
124
  end
@@ -67,6 +67,8 @@ module Fp
67
67
  puts "Slug: #{req['slug']}"
68
68
  puts "Status: #{req['status']}"
69
69
  puts "Category: #{req['category'] || '(none)'}"
70
+ tags = req['tags'] || []
71
+ puts "Tags: #{tags.empty? ? '(none)' : tags.join(', ')}"
70
72
  puts "Parent: #{parent_slug || req['parent_id'] || '(none)'}"
71
73
  puts
72
74
 
@@ -110,6 +112,7 @@ module Fp
110
112
  puts " File: #{loc}"
111
113
  end
112
114
  puts " Repo: #{e['repo']}" if e['repo']
115
+ puts " Commit: #{e['commit_sha']}" if e['commit_sha']
113
116
  puts " PR: #{e['pr_url']}" if e['pr_url']
114
117
  puts " Work: #{e['work_item_url']}" if e['work_item_url']
115
118
  puts " CI: #{e['ci_url']}" if e['ci_url']
@@ -1,8 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+
3
5
  module Fp
4
6
  module Commands
5
7
  # fp surfaces --project <slug> - list available surfaces for a project
8
+ # fp surfaces show <key> --project <slug> - view a single surface's details
6
9
  # fp surfaces add <key> --project <slug> - create a surface
7
10
  #
8
11
  # `add` exists because a requirement can only name surfaces that already exist, so an
@@ -24,8 +27,11 @@ module Fp
24
27
  def run(args)
25
28
  # Subcommand dispatch. Bare `fp surfaces --project X` stays a list, so existing
26
29
  # callers (and the docs) keep working.
27
- if args.first == 'add'
30
+ case args.first
31
+ when 'add'
28
32
  add(args[1..])
33
+ when 'show'
34
+ show(args[1..])
29
35
  else
30
36
  list(args)
31
37
  end
@@ -59,6 +65,31 @@ module Fp
59
65
  end
60
66
  end
61
67
 
68
+ def show(args)
69
+ opts, positional = parse_flags(args)
70
+ require_project!(opts)
71
+
72
+ key = positional.first
73
+ if key.nil? || key.strip.empty?
74
+ output.error('Usage: fp surfaces show <key> --project <slug>')
75
+ exit 1
76
+ end
77
+
78
+ project_id = resolve_project_id(opts[:project])
79
+ result = client.find_surface_by_key(project_id, key)
80
+
81
+ unless result[:ok]
82
+ output.error(result[:error], status: result[:status])
83
+ exit 1
84
+ end
85
+
86
+ surface = result[:data]['surface'] || {}
87
+
88
+ output.success(result[:data]) do
89
+ print_surface(surface, project: opts[:project])
90
+ end
91
+ end
92
+
62
93
  def add(args)
63
94
  opts, positional = parse_flags(args, ADD_FLAGS)
64
95
  require_project!(opts)
@@ -108,6 +139,54 @@ module Fp
108
139
  output.error("Invalid #{flag} '#{value}'. Must be one of: #{allowed.join(', ')}")
109
140
  exit 1
110
141
  end
142
+
143
+ # Human-readable detail view for a single surface. Mirrors the shape of
144
+ # `fp show` for requirements: title underline, then the fields that matter.
145
+ def print_surface(surface, project:)
146
+ name = surface['name'] || surface['key'].to_s
147
+ puts name
148
+ puts '=' * name.length
149
+ puts
150
+ puts "Key: #{surface['key']}"
151
+ puts "Project: #{project}"
152
+ puts "Kind: #{surface['kind'] || '(none)'}"
153
+ puts "Audience: #{surface['audience'] || '(none)'}"
154
+ puts "Repo: #{surface['repo'] || '(none)'}"
155
+ puts "Branch: #{surface['default_branch'] || '(none)'}" if surface.key?('default_branch')
156
+
157
+ owner = surface['owner_id']
158
+ team = surface['team']
159
+ puts "Owner: #{owner && !owner.to_s.empty? ? owner : '(unassigned)'}"
160
+ puts "Team: #{team && !team.to_s.empty? ? team : '(none)'}"
161
+
162
+ globs = normalize_test_globs(surface['test_globs'])
163
+ unless globs.empty?
164
+ puts
165
+ puts 'Test Globs:'
166
+ globs.each { |g| puts " - #{g}" }
167
+ end
168
+
169
+ puts
170
+ puts "ID: #{surface['id']}" if surface['id']
171
+ puts "Created: #{surface['created_at']}" if surface['created_at']
172
+ puts "Updated: #{surface['updated_at']}" if surface['updated_at']
173
+ end
174
+
175
+ # test_globs may arrive as a JSON string or an array; normalize to an array for
176
+ # display. Blank/unparseable → empty (the server falls back to kind defaults).
177
+ def normalize_test_globs(value)
178
+ case value
179
+ when Array then value
180
+ when String
181
+ stripped = value.strip
182
+ return [] if stripped.empty?
183
+
184
+ parsed = JSON.parse(stripped) rescue nil
185
+ parsed.is_a?(Array) ? parsed : []
186
+ else
187
+ []
188
+ end
189
+ end
111
190
  end
112
191
  end
113
192
  end
data/lib/fp/parity.rb CHANGED
@@ -17,10 +17,11 @@ module Fp
17
17
  # here rather than changing any verdict.
18
18
  module Parity
19
19
  # Worst → best, matching the server's severity order. Used for stable output.
20
- STATES = %w[failing missing suspect stub present passing].freeze
20
+ STATES = %w[failing missing suspect stub present pr_passing passing].freeze
21
21
 
22
22
  GLYPHS = {
23
23
  'passing' => '✓',
24
+ 'pr_passing' => '◐',
24
25
  'failing' => '✗',
25
26
  'present' => '●',
26
27
  'stub' => '○',
@@ -42,7 +43,7 @@ module Fp
42
43
 
43
44
  LEVELS = %w[off regression covered strict].freeze
44
45
 
45
- LEGEND = 'Legend: ✓ passing ✗ failing ● present ○ stub ⚠ suspect · missing - not required'
46
+ LEGEND = 'Legend: ✓ passing ◐ pr-passing ✗ failing ● present ○ stub ⚠ suspect · missing - not required'
46
47
 
47
48
  # Appended to the legend only when the grid actually contains one, so the common
48
49
  # case isn't cluttered by vocabulary nothing on screen uses.
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.7'
4
+ VERSION = '0.0.9'
5
5
  end
data/skills/fp/SKILL.md CHANGED
@@ -112,11 +112,24 @@ fp report --junit junit.xml \
112
112
  fp propose --project stowzilla \
113
113
  --slug print_container_qr \
114
114
  --name "Print QR on container label" \
115
- --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" \
116
116
  --required api,customer_android \
117
- --acceptance "Label shows scannable QR code"
117
+ --acceptance "QR scans to the container's detail page"
118
118
  ```
119
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
+
120
133
  **Agents always create requirements as draft.** A human must activate in the web app.
121
134
 
122
135
  ### 6. Propose a change to an existing requirement
@@ -136,9 +136,9 @@ Propose a new requirement as draft.
136
136
  fp propose --project stowzilla \
137
137
  --slug print_container_qr \
138
138
  --name "Print QR on container label" \
139
- --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" \
140
140
  --required api,customer_android \
141
- --acceptance "Label shows scannable QR code"
141
+ --acceptance "QR scans to the container's detail page"
142
142
  ```
143
143
 
144
144
  | Flag | Required | Description |
@@ -146,9 +146,18 @@ fp propose --project stowzilla \
146
146
  | `--project` | Yes | Project slug |
147
147
  | `--slug` | Yes | Requirement slug (immutable, choose carefully) |
148
148
  | `--name` | Yes | Human-readable name |
149
- | `--why` | No | Why this requirement matters |
149
+ | `--why` | No | One plain sentence: the user need or outcome the feature serves (see below) |
150
150
  | `--required` | No | Comma-separated surfaces that must implement this |
151
- | `--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)
152
161
 
153
162
  **Agents always create requirements as draft.** A human must activate in the web app.
154
163
 
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.7
4
+ version: 0.0.9
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-17 00:00:00.000000000 Z
11
+ date: 2026-09-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: net-http