featureparity 0.0.7 → 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.
- checksums.yaml +4 -4
- data/lib/fp/client.rb +12 -7
- data/lib/fp/commands/ci_report.rb +13 -4
- data/lib/fp/commands/help.rb +25 -3
- data/lib/fp/commands/init.rb +13 -0
- data/lib/fp/commands/list.rb +10 -5
- data/lib/fp/commands/propose.rb +9 -1
- data/lib/fp/commands/report.rb +3 -0
- data/lib/fp/commands/show.rb +3 -0
- data/lib/fp/parity.rb +3 -2
- data/lib/fp/version.rb +1 -1
- data/skills/fp/SKILL.md +15 -2
- data/skills/fp/references/cli.md +13 -4
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c622199401f105760e517583ace2bb9ec9da7f6f125d960144783ec82e2922b4
|
|
4
|
+
data.tar.gz: ab0b372febeff5b863f46d80d093ff8ac789bb4fdd2ae9ceab443f81a8b9a2f5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ed416b54e77e1aa6e9ce490283811ac5b81443f34aef97cf9b47987719bc5b5497456c4fd3bb74b1f308b735ab07bc5682a06a4d13548cd48272135160692cd2
|
|
7
|
+
data.tar.gz: 6316ceda063c279ad46dd763c5ab9b1366a27cd00c1eb8d6f52b7ce21995c203040f86667e76a4a921e77d7a14c380ec85aaedb1ad0c4468e8284b185cf69a5c
|
data/lib/fp/client.rb
CHANGED
|
@@ -33,11 +33,12 @@ module Fp
|
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
# GET /requirements
|
|
36
|
-
def list_requirements(project_id: nil, status: nil, category: nil)
|
|
36
|
+
def list_requirements(project_id: nil, status: nil, category: nil, tag: nil)
|
|
37
37
|
params = {}
|
|
38
38
|
params[:project_id] = project_id if project_id
|
|
39
39
|
params[:status] = status if status
|
|
40
40
|
params[:category] = category if category
|
|
41
|
+
params[:tag] = tag if tag
|
|
41
42
|
get('/api/requirements', params)
|
|
42
43
|
end
|
|
43
44
|
|
|
@@ -118,12 +119,16 @@ module Fp
|
|
|
118
119
|
# writes passing/failing evidence per surface. It also marks previously
|
|
119
120
|
# passing slugs that have vanished from the results as failing. This is the
|
|
120
121
|
# half of the loop only CI is allowed to close (#1222).
|
|
121
|
-
def ingest_ci_results(project_id, ci_url:, surface:, junit_xml:)
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
122
|
+
def ingest_ci_results(project_id, ci_url:, surface:, junit_xml:, pr_url: nil)
|
|
123
|
+
body = {
|
|
124
|
+
ci_url: ci_url,
|
|
125
|
+
surface: surface,
|
|
126
|
+
junit_xml: junit_xml
|
|
127
|
+
}
|
|
128
|
+
# Only send pr_url when set, so a merged-branch run stays a plain ingest.
|
|
129
|
+
body[:pr_url] = pr_url if pr_url && !pr_url.empty?
|
|
130
|
+
|
|
131
|
+
post("/api/projects/#{project_id}/ci_results", body)
|
|
127
132
|
end
|
|
128
133
|
|
|
129
134
|
# 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
|
-
|
|
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
|
|
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
|
data/lib/fp/commands/help.rb
CHANGED
|
@@ -25,6 +25,7 @@ module Fp
|
|
|
25
25
|
report <slug> --project <slug>... Report evidence for a requirement
|
|
26
26
|
report --junit <file> ... Upload evidence from a JUnit XML report (no CI)
|
|
27
27
|
ci-report --junit <file> ... Ingest CI test results (passing/failing) — CI only
|
|
28
|
+
ci-report --junit <file> --pr URL Ingest a PR's CI results as pr_passing — CI only
|
|
28
29
|
check Enforce parity; exits non-zero on violations
|
|
29
30
|
matrix --project <slug> Show the parity matrix
|
|
30
31
|
export --project <slug> Export all requirements as a Markdown document
|
|
@@ -61,6 +62,14 @@ module Fp
|
|
|
61
62
|
at once. `--level` overrides it for one run; `--warn-only` reports violations but
|
|
62
63
|
still exits 0, for adopting the gate on a repo that isn't clean yet.
|
|
63
64
|
|
|
65
|
+
PR-VERIFIED EVIDENCE
|
|
66
|
+
`ci-report --pr <url>` marks a run as a pull request's CI. Passing tests are then
|
|
67
|
+
recorded as `pr_passing` ("would pass once merged") instead of `passing`, and no
|
|
68
|
+
merged evidence is demoted. It ranks between `present` and `passing`: it counts as
|
|
69
|
+
covered at the `covered` level, but only `strict` accepts it — strict wants the
|
|
70
|
+
merged-green truth. Run ci-report *without* --pr on a push to the default branch to
|
|
71
|
+
record the real `passing`, which supersedes any pr_passing a prior PR left behind.
|
|
72
|
+
|
|
64
73
|
AUTHENTICATION
|
|
65
74
|
Set FP_API_KEY environment variable, or use a profile:
|
|
66
75
|
|
|
@@ -124,6 +133,9 @@ module Fp
|
|
|
124
133
|
# List requirements filtered by category
|
|
125
134
|
fp list --project stowzilla --category security
|
|
126
135
|
|
|
136
|
+
# List requirements filtered by tag (free-form label)
|
|
137
|
+
fp list --project stowzilla --tag mobile
|
|
138
|
+
|
|
127
139
|
# Show gaps: requirements with a required surface that has no real evidence
|
|
128
140
|
# (missing, stubbed, or failing). Unlike `fp list`, this reads evidence.
|
|
129
141
|
fp list --project stowzilla --gaps --surface customer_android
|
|
@@ -132,13 +144,23 @@ module Fp
|
|
|
132
144
|
fp show print_container_qr --project stowzilla
|
|
133
145
|
|
|
134
146
|
# Propose a new requirement (creates as draft)
|
|
147
|
+
#
|
|
148
|
+
# --why must be brief and actually explain why: whoever approves it should grasp
|
|
149
|
+
# in one read the user need or outcome the feature serves. Longer is fine when it
|
|
150
|
+
# earns its length; wordy is not. Avoid filler, and avoid describing HOW it works
|
|
151
|
+
# instead of WHY anyone wants it.
|
|
152
|
+
# good: "Staff scan the label to find a container; without a QR they key the ID by hand and mis-type it."
|
|
153
|
+
# bad: "Ensures a high-quality experience across all surfaces." (filler)
|
|
154
|
+
# --acceptance is the observable condition that proves it's done, not a
|
|
155
|
+
# restatement of the title.
|
|
135
156
|
fp propose --project stowzilla \\
|
|
136
157
|
--slug print_container_qr \\
|
|
137
158
|
--name "Print QR code on container label" \\
|
|
138
|
-
--why "
|
|
159
|
+
--why "Staff scan the label to find a container; without a QR they key the ID by hand and mis-type it" \\
|
|
139
160
|
--required api,customer_android,customer_ios \\
|
|
140
|
-
--acceptance "QR
|
|
141
|
-
--category functional
|
|
161
|
+
--acceptance "QR scans to the container's detail page" \\
|
|
162
|
+
--category functional \\
|
|
163
|
+
--tags mobile,labeling,q3
|
|
142
164
|
|
|
143
165
|
# Nest a requirement under a parent (--parent takes the parent's SLUG).
|
|
144
166
|
# A child's --required surfaces must be a subset of its parent's.
|
data/lib/fp/commands/init.rb
CHANGED
|
@@ -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.
|
data/lib/fp/commands/list.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
data/lib/fp/commands/propose.rb
CHANGED
|
@@ -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
|
data/lib/fp/commands/report.rb
CHANGED
data/lib/fp/commands/show.rb
CHANGED
|
@@ -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']
|
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
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 "
|
|
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 "
|
|
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
|
data/skills/fp/references/cli.md
CHANGED
|
@@ -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 "
|
|
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 "
|
|
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 |
|
|
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 |
|
|
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
|
|