featureparity 0.0.14 → 0.0.16

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.
@@ -0,0 +1,359 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'openssl'
6
+ require 'uri'
7
+
8
+ module Fp
9
+ module Commands
10
+ # `fp changes` lists requirement activity associated with a work item. In GitHub
11
+ # Actions it can discover source URLs from the pull request body, append a job
12
+ # summary, and keep a managed table in the pull request description itself.
13
+ class Changes < Base
14
+ PR_SECTION_BEGIN = '<!-- feature-parity:requirement-changes:begin -->'
15
+ PR_SECTION_END = '<!-- feature-parity:requirement-changes:end -->'
16
+
17
+ KNOWN_FLAGS = {
18
+ project: :string,
19
+ source_url: :string,
20
+ github_summary: :boolean,
21
+ github_pr: :boolean
22
+ }.freeze
23
+
24
+ TYPE_LABELS = {
25
+ 'created' => 'Created',
26
+ 'updated' => 'Edited',
27
+ 'activated' => 'Activated',
28
+ 'archived' => 'Archived',
29
+ 'rejected' => 'Rejected',
30
+ 'destroyed' => 'Deleted',
31
+ 'change_proposed' => 'Modification proposed',
32
+ 'removal_proposed' => 'Removal proposed',
33
+ 'change_approved' => 'Change approved',
34
+ 'change_rejected' => 'Change rejected'
35
+ }.freeze
36
+
37
+ def run(args)
38
+ explicit_urls, rest = extract_repeated(args, '--source-url')
39
+ opts, = parse_flags(rest, KNOWN_FLAGS)
40
+ project_config = ProjectConfig.discover
41
+ project_slug = require_project!(opts, project_config)
42
+ @github_pull_request = fetch_github_pull_request if opts[:github_pr]
43
+
44
+ source_urls = explicit_urls.map(&:strip).reject(&:empty?).uniq
45
+ source_urls = github_source_urls if source_urls.empty?
46
+
47
+ data = {
48
+ project: project_slug,
49
+ source_urls: source_urls,
50
+ requirement_changes: [],
51
+ source_detected: !source_urls.empty?
52
+ }
53
+
54
+ if source_urls.any?
55
+ project_id = resolve_project_id(project_slug)
56
+ data[:requirement_changes] = fetch_changes(project_id, source_urls)
57
+ end
58
+
59
+ markdown = render_markdown(data)
60
+ destinations = []
61
+
62
+ if opts[:github_summary]
63
+ append_github_summary(markdown)
64
+ destinations << ENV['GITHUB_STEP_SUMMARY']
65
+ end
66
+ if opts[:github_pr]
67
+ update_github_pull_request(markdown)
68
+ destinations << 'the pull request description'
69
+ end
70
+
71
+ output.success(data) do
72
+ if destinations.any?
73
+ puts "FeatureParity requirement-change table updated in #{destinations.join(' and ')}."
74
+ else
75
+ puts markdown
76
+ end
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def fetch_changes(project_id, source_urls)
83
+ changes = []
84
+
85
+ source_urls.each do |source_url|
86
+ result = client.list_project_requirement_changes(project_id, source_url: source_url)
87
+ if [400, 403, 404].include?(result[:status].to_i) && result[:error].to_s.match?(/bad request|not found/i)
88
+ return legacy_changes(project_id, source_urls)
89
+ end
90
+
91
+ unless result[:ok]
92
+ output.error(result[:error], status: result[:status])
93
+ exit 1
94
+ end
95
+
96
+ changes.concat(result[:data]['requirement_changes'] || [])
97
+ end
98
+
99
+ collapse_to_latest_per_requirement(
100
+ changes.uniq { |change| change['id'] }
101
+ .sort_by { |change| change['recorded_at'].to_s }
102
+ .reverse
103
+ )
104
+ end
105
+
106
+ # Option A: one row per requirement. A requirement whose creation and a later
107
+ # modification proposal both trace to the same work item would otherwise appear
108
+ # twice, which reads as a duplicate. We keep only the most recent event per
109
+ # requirement, so the table answers "where does each requirement stand now?"
110
+ # rather than replaying its whole timeline (that lives in `fp history <slug>`).
111
+ def collapse_to_latest_per_requirement(changes)
112
+ changes.each_with_object({}) do |change, latest|
113
+ key = change['slug'] || change['requirement_path'] || change['id']
114
+ latest[key] ||= change
115
+ end.values
116
+ end
117
+
118
+ # Compatibility with an API that predates the project activity endpoint. This is
119
+ # especially useful while a CLI/API release is rolling out: requirements whose own
120
+ # source matches the work item, plus every open proposal, are inspected through the
121
+ # existing per-requirement history endpoint. Once the endpoint is deployed, its
122
+ # ProjectIndex also covers reviewed and deleted activity without this bounded scan.
123
+ def legacy_changes(project_id, source_urls)
124
+ result = client.list_requirements(project_id: project_id)
125
+ unless result[:ok]
126
+ output.error(result[:error], status: result[:status])
127
+ exit 1
128
+ end
129
+
130
+ requirements = result[:data]['requirements'] || []
131
+ candidates = requirements.select do |requirement|
132
+ source_urls.include?(requirement['source_url']) || present?(requirement['pending_change_id'])
133
+ end
134
+
135
+ collapsed = candidates.flat_map do |requirement|
136
+ history = client.list_requirement_changes(requirement['id'])
137
+ next [] unless history[:ok]
138
+
139
+ (history[:data]['changes'] || []).filter_map do |change|
140
+ next unless source_urls.include?(change.dig('source', 'url'))
141
+
142
+ change.merge(
143
+ 'slug' => requirement['slug'],
144
+ 'title' => requirement['title'],
145
+ 'requirement_status' => requirement['status'],
146
+ 'requirement_path' => "/projects/#{project_id}/requirements/#{requirement['id']}",
147
+ 'review_state' => change.dig('review', 'decision') || (change['pending'] ? 'pending' : requirement['status'])
148
+ )
149
+ end
150
+ end.uniq { |change| change['id'] }
151
+ .sort_by { |change| change['recorded_at'].to_s }
152
+ .reverse
153
+
154
+ collapse_to_latest_per_requirement(collapsed)
155
+ end
156
+
157
+ def github_source_urls
158
+ body = if @github_pull_request
159
+ @github_pull_request.fetch('body', '').to_s
160
+ else
161
+ github_event.dig('pull_request', 'body').to_s
162
+ end
163
+
164
+ body = body.sub(/#{Regexp.escape(PR_SECTION_BEGIN)}.*?#{Regexp.escape(PR_SECTION_END)}/m, '')
165
+
166
+ # Option B: explicit fp-source markers take precedence
167
+ explicit = body.scan(/fp-source:\s*(https?:\/\/\S+)/i).flatten.map { |url| clean_url(url) }
168
+ return explicit.uniq if explicit.any?
169
+
170
+ # Option A: heuristic detection of ticket-tracker URLs
171
+ URI.extract(body, %w[http https])
172
+ .map { |url| clean_url(url) }
173
+ .select { |url| work_item_url?(url) }
174
+ .uniq
175
+ end
176
+
177
+ def github_event
178
+ return @github_event if defined?(@github_event)
179
+
180
+ event_path = ENV['GITHUB_EVENT_PATH']
181
+ @github_event = if present?(event_path) && File.file?(event_path)
182
+ JSON.parse(File.read(event_path))
183
+ else
184
+ {}
185
+ end
186
+ rescue JSON::ParserError
187
+ @github_event = {}
188
+ end
189
+
190
+ def work_item_url?(url)
191
+ # Heuristic detection: accept URLs that look like ticket trackers.
192
+ # Matches paths containing common ticket-tracker segments.
193
+ url.match?(%r{
194
+ \Ahttps?://[^/]+ # scheme and host
195
+ (?:
196
+ .*/(?:issues|cards|browse|tickets|tasks|work[-_]?items?|bugs?|stories?|features?|pull|merge_requests?|issue|story|_workitems|channels|c|t)/[^\s/]+ |
197
+ .*/\d+(?:/|\z) # any path ending in /123 or /123/
198
+ )
199
+ }xi)
200
+ end
201
+
202
+ def clean_url(url)
203
+ url.sub(/[)\]}>.,;:]+\z/, '')
204
+ end
205
+
206
+ def append_github_summary(markdown)
207
+ path = ENV['GITHUB_STEP_SUMMARY']
208
+ unless present?(path)
209
+ output.error('--github-summary requires GITHUB_STEP_SUMMARY (it is set automatically by GitHub Actions)')
210
+ exit 1
211
+ end
212
+
213
+ File.open(path, 'a') { |file| file.puts(markdown) }
214
+ end
215
+
216
+ def fetch_github_pull_request
217
+ token, uri = github_credentials
218
+ response = github_request(Net::HTTP::Get, uri, token)
219
+ fail_github_request!('read the pull request', response) unless response.is_a?(Net::HTTPSuccess)
220
+
221
+ JSON.parse(response.body)
222
+ rescue JSON::ParserError
223
+ output.error('GitHub returned an invalid response while reading the pull request')
224
+ exit 1
225
+ rescue SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError => e
226
+ output.error("Could not read the pull request from GitHub: #{e.message}")
227
+ exit 1
228
+ end
229
+
230
+ def update_github_pull_request(markdown)
231
+ current_body = @github_pull_request.fetch('body', '').to_s
232
+ updated_body = managed_pull_request_body(current_body, markdown)
233
+ return if updated_body == current_body
234
+
235
+ token, uri = github_credentials
236
+ response = github_request(Net::HTTP::Patch, uri, token, body: { body: updated_body })
237
+ fail_github_request!('update the pull request description', response) unless response.is_a?(Net::HTTPSuccess)
238
+ rescue SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError => e
239
+ output.error("Could not update the pull request description on GitHub: #{e.message}")
240
+ exit 1
241
+ end
242
+
243
+ def managed_pull_request_body(body, markdown)
244
+ section = "#{PR_SECTION_BEGIN}\n#{markdown}\n#{PR_SECTION_END}"
245
+ begin_count = body.scan(PR_SECTION_BEGIN).length
246
+ end_count = body.scan(PR_SECTION_END).length
247
+
248
+ unless [begin_count, end_count].all? { |count| [0, 1].include?(count) } && begin_count == end_count
249
+ output.error('The pull request description has malformed or duplicate FeatureParity requirement-change markers')
250
+ exit 1
251
+ end
252
+
253
+ if begin_count == 1
254
+ body.sub(/#{Regexp.escape(PR_SECTION_BEGIN)}.*?#{Regexp.escape(PR_SECTION_END)}/m, section)
255
+ else
256
+ [body.rstrip, section].reject(&:empty?).join("\n\n") + "\n"
257
+ end
258
+ end
259
+
260
+ def github_credentials
261
+ token = ENV['GITHUB_TOKEN']
262
+ repository = ENV['GITHUB_REPOSITORY'].to_s
263
+ number = github_event.dig('pull_request', 'number') || github_event['number']
264
+
265
+ unless present?(token) && repository.match?(%r{\A[^/]+/[^/]+\z}) && number.to_i.positive?
266
+ output.error('--github-pr requires GITHUB_TOKEN, GITHUB_REPOSITORY, and a pull_request GITHUB_EVENT_PATH')
267
+ exit 1
268
+ end
269
+
270
+ owner, repo = repository.split('/', 2).map { |part| URI.encode_www_form_component(part) }
271
+ api_url = ENV.fetch('GITHUB_API_URL', 'https://api.github.com').sub(%r{/+\z}, '')
272
+ [token, URI.parse("#{api_url}/repos/#{owner}/#{repo}/pulls/#{number.to_i}")]
273
+ rescue URI::InvalidURIError
274
+ output.error('GITHUB_API_URL is not a valid URL')
275
+ exit 1
276
+ end
277
+
278
+ def github_request(request_class, uri, token, body: nil)
279
+ request = request_class.new(uri)
280
+ request['Accept'] = 'application/vnd.github+json'
281
+ request['Authorization'] = "Bearer #{token}"
282
+ request['User-Agent'] = 'featureparity-cli'
283
+ request['X-GitHub-Api-Version'] = '2022-11-28'
284
+ if body
285
+ request['Content-Type'] = 'application/json'
286
+ request.body = JSON.generate(body)
287
+ end
288
+
289
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
290
+ http.request(request)
291
+ end
292
+ end
293
+
294
+ def fail_github_request!(action, response)
295
+ details = JSON.parse(response.body).fetch('message', response.body.to_s)
296
+ output.error("GitHub could not #{action}: #{details}", status: response.code.to_i)
297
+ exit 1
298
+ rescue JSON::ParserError
299
+ output.error("GitHub could not #{action}: HTTP #{response.code}", status: response.code.to_i)
300
+ exit 1
301
+ end
302
+
303
+ def render_markdown(data)
304
+ lines = ['## FeatureParity requirement changes', '']
305
+
306
+ unless data[:source_detected]
307
+ lines << '⚠️ No work-item URL was found in the pull request description, so FeatureParity cannot determine which requirement activity belongs to this change.'
308
+ return lines.join("\n")
309
+ end
310
+
311
+ links = data[:source_urls].map { |url| "[#{markdown_escape(url)}](#{url})" }
312
+ lines << "Work item#{links.length == 1 ? '' : 's'}: #{links.join(', ')}"
313
+ lines << ''
314
+
315
+ changes = data[:requirement_changes]
316
+ if changes.empty?
317
+ lines << 'No requirement changes are recorded for the linked work item(s).'
318
+ return lines.join("\n")
319
+ end
320
+
321
+ lines << '| Requirement | Change | Review | Actor |'
322
+ lines << '| --- | --- | --- | --- |'
323
+ changes.each do |change|
324
+ lines << "| #{requirement_cell(change)} | #{TYPE_LABELS.fetch(change['type'], change['type'])} | " \
325
+ "#{markdown_escape(change['review_state'] || change['requirement_status'] || '—')} | " \
326
+ "#{markdown_escape(change['actor'] || '—')} |"
327
+ end
328
+ lines << ''
329
+ lines << '_Open the linked requirement to review its wording, diff, evidence, and decision state._'
330
+ lines.join("\n")
331
+ end
332
+
333
+ def requirement_cell(change)
334
+ slug = change['slug'] || 'unknown'
335
+ title = markdown_escape(change['title'] || slug)
336
+ path = change['requirement_path']
337
+ return title unless present?(path)
338
+
339
+ "[#{title}](#{web_base_url}#{path})"
340
+ end
341
+
342
+ def web_base_url
343
+ uri = URI.parse(config.api_url)
344
+ host = uri.host.to_s.sub(/\Aapi\./, '')
345
+ "#{uri.scheme || 'https'}://#{host}"
346
+ rescue URI::InvalidURIError
347
+ 'https://featureparity.dev'
348
+ end
349
+
350
+ def markdown_escape(value)
351
+ value.to_s.gsub('|', '\\|').gsub("\n", ' ')
352
+ end
353
+
354
+ def present?(value)
355
+ !value.nil? && !value.to_s.strip.empty?
356
+ end
357
+ end
358
+ end
359
+ end
@@ -39,6 +39,7 @@ module Fp
39
39
  KNOWN_FLAGS = {
40
40
  project: :string,
41
41
  surface: :string,
42
+ test_type: :string,
42
43
  level: :string,
43
44
  junit: :string,
44
45
  base_dir: :string,
@@ -126,7 +127,8 @@ module Fp
126
127
 
127
128
  results = junit_paths.flat_map do |path|
128
129
  testcases = Fp::JUnit.parse_file(path)
129
- Fp::JUnit.ci_results(testcases, base_dir: base_dir, default_surface: surface)[:results]
130
+ Fp::JUnit.ci_results(testcases, base_dir: base_dir, default_surface: surface,
131
+ default_test_type: opts[:test_type])[:results]
130
132
  end
131
133
 
132
134
  if results.empty?
@@ -141,6 +143,7 @@ module Fp
141
143
  {
142
144
  slug: r.slug,
143
145
  surface: r.surface,
146
+ test_type: r.test_type,
144
147
  status: r.status,
145
148
  source_file: r.file,
146
149
  source_line: r.line,
@@ -219,15 +222,35 @@ module Fp
219
222
  return if violations.empty?
220
223
 
221
224
  puts "#{violations.size} violation(s):"
222
- output.table(
223
- %w[SLUG SURFACE STATE WHY],
224
- violations.map do |v|
225
- [v['slug'], v['surface'], "#{Parity.glyph(v['state'])} #{v['state']}", v['reason']]
226
- end
227
- )
225
+ # Show the TYPE column only when some requirement demands specific test types —
226
+ # otherwise it's an empty column of dashes on every project that doesn't use the
227
+ # feature.
228
+ if violations.any? { |v| presence(v['test_type']) }
229
+ output.table(
230
+ %w[SLUG SURFACE TYPE STATE WHY],
231
+ violations.map do |v|
232
+ [v['slug'], v['surface'], v['test_type'] || '—',
233
+ "#{Parity.glyph(v['state'])} #{v['state']}", v['reason']]
234
+ end
235
+ )
236
+ else
237
+ output.table(
238
+ %w[SLUG SURFACE STATE WHY],
239
+ violations.map do |v|
240
+ [v['slug'], v['surface'], "#{Parity.glyph(v['state'])} #{v['state']}", v['reason']]
241
+ end
242
+ )
243
+ end
228
244
  puts
229
245
  end
230
246
 
247
+ # nil/blank → nil, so `presence(x)` reads as falsey in a guard.
248
+ def presence(value)
249
+ return nil if value.nil? || value.to_s.strip.empty?
250
+
251
+ value
252
+ end
253
+
231
254
  # Markers the run carried that bind to no requirement in this project — the test
232
255
  # claims to cover something that doesn't exist. FP identifies them; it does not
233
256
  # propose the requirement, because a slug and a test name aren't enough to write a
@@ -50,6 +50,7 @@ module Fp
50
50
  KNOWN_FLAGS = {
51
51
  project: :string,
52
52
  surface: :string,
53
+ test_type: :string,
53
54
  ci_url: :string,
54
55
  pr: :string,
55
56
  junit: :string,
@@ -214,7 +215,8 @@ module Fp
214
215
  resolved = Fp::JUnit.ci_results(
215
216
  testcases,
216
217
  base_dir: opts[:base_dir] || Dir.pwd,
217
- default_surface: opts[:surface]
218
+ default_surface: opts[:surface],
219
+ default_test_type: opts[:test_type]
218
220
  )
219
221
 
220
222
  {
@@ -222,9 +224,11 @@ module Fp
222
224
  {
223
225
  slug: r.slug,
224
226
  surface: r.surface,
227
+ test_type: r.test_type,
225
228
  status: r.status,
226
229
  source_file: r.file,
227
230
  source_line: r.line,
231
+ title: r.title,
228
232
  test_count: r.test_count,
229
233
  failure_count: r.failure_count
230
234
  }
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
4
+ module Commands
5
+ # fp conversation <conversation_id>
6
+ # Read a Pari conversation so agents can review AI suggestions and implement them.
7
+ #
8
+ # A conversation is just a record identified by its id — the command doesn't care,
9
+ # and doesn't ask, whether it came from a project panel or the account-level one.
10
+ # The server resolves the owner and authorizes the caller. That's the whole point:
11
+ # a user copies a reference, hands it to an agent, and the agent reads it.
12
+ #
13
+ # Typical workflow:
14
+ # 1. User opens Pari, has a conversation that suggests requirements or code
15
+ # 2. User clicks "Copy Reference" (or "Copy Prompt") to get the lookup command
16
+ # 3. User gives it to an agent: "implement what Pari suggested in <id>"
17
+ # 4. Agent runs `fp conversation <id>` to read the full context
18
+ # 5. Agent implements based on Pari's suggestions
19
+ class Conversation < Base
20
+ def run(args)
21
+ _opts, positional = parse_flags(args)
22
+ conversation_id = positional.first
23
+
24
+ unless conversation_id
25
+ output.error(<<~MSG.strip)
26
+ Conversation ID is required.
27
+
28
+ Usage:
29
+ fp conversation <id> Read a Pari conversation
30
+
31
+ The conversation ID is a KSUID like "03h0abc123xyz...". Copy it from Pari's
32
+ header (the copy buttons next to the history icon), or use "Copy Reference"
33
+ to get this whole command ready to paste.
34
+ MSG
35
+ exit 1
36
+ end
37
+
38
+ result = client.get_conversation(conversation_id)
39
+
40
+ unless result[:ok]
41
+ output.error(result[:error], status: result[:status])
42
+ exit 1
43
+ end
44
+
45
+ conversation = result[:data]['conversation']
46
+ messages = result[:data]['messages'] || []
47
+
48
+ output.success(result[:data]) do
49
+ print_conversation(conversation, messages)
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def print_conversation(conversation, messages)
56
+ title = conversation['title'] || 'Untitled conversation'
57
+ puts title
58
+ puts '=' * title.length
59
+ puts
60
+ puts "ID: #{conversation['id']}"
61
+ puts "Project: #{conversation['project_id']}" if conversation['project_id'].to_s != ''
62
+ puts "Status: #{conversation['status']}"
63
+ puts "Created: #{conversation['created_at']}"
64
+ puts "Updated: #{conversation['updated_at']}"
65
+ puts
66
+
67
+ if messages.empty?
68
+ puts '(no messages)'
69
+ else
70
+ puts 'Messages:'
71
+ puts '-' * 40
72
+ messages.each_with_index do |msg, idx|
73
+ role = msg['role'] == 'user' ? 'USER' : 'PARI'
74
+ puts
75
+ puts "[#{idx + 1}] #{role}:"
76
+ puts msg['content']
77
+
78
+ # Show any requirements Pari created during this turn
79
+ created = msg['created_requirement_ids'] || []
80
+ unless created.empty?
81
+ puts
82
+ puts " → Created draft requirement(s): #{created.join(', ')}"
83
+ end
84
+
85
+ puts
86
+ puts '-' * 40
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -23,6 +23,12 @@ module Fp
23
23
  propose-change <slug> ... Propose a change to an existing requirement
24
24
  propose-change <slug> --removal Propose deleting a requirement
25
25
  history <slug> --project <slug> Show a requirement's change history + diffs
26
+ changes [--source-url URL] [--github-pr] Show or publish work-item-linked activity
27
+ attach <slug> <file> Upload a file attachment to a requirement
28
+ attachments <slug> List attachments for a requirement
29
+ attachments <slug> <id> Get download URL for an attachment
30
+ attach rm <slug> <id> Delete an attachment
31
+ conversation <id> Read a Pari conversation for agent review
26
32
  report <slug> --project <slug>... Report evidence for a requirement
27
33
  report --junit <file> ... Upload evidence from a JUnit XML report (no CI)
28
34
  ci-report --junit <file> ... Ingest CI test results (passing/failing) — CI only
@@ -151,16 +157,45 @@ module Fp
151
157
  # Show a specific requirement
152
158
  fp show print_container_qr --project stowzilla
153
159
 
154
- # Propose a new requirement (creates as draft)
160
+ # Propose a new requirement (creates as draft).
161
+ # A requirement records the current shared understanding, not a decree handed from
162
+ # product to engineering. Refine it through conversation with customers, domain
163
+ # experts, and builders. Humans decide value and priority; agents surface gaps,
164
+ # inconsistencies, alternatives, and useful splits. Prefer outcomes over feature lists.
165
+ # Use available evidence: what people attempt, abandon, actually use, and what
166
+ # experiments reveal. The first requested solution is input, not unquestionable scope.
167
+ # First inventory the whole request and check active AND draft requirements. Give
168
+ # each independently testable/failable product promise a focused requirement; use
169
+ # a parent plus children for a broad capability with distinct behaviours or policy.
170
+ # Record concrete decisions (prices, quotas, dates, roles, fallbacks) in acceptance,
171
+ # and propose changes to adjacent requirements the work makes inaccurate.
172
+ # Before adding or changing a promise, ask whether it and related active/draft
173
+ # requirements can both be true for the same user, state, and conditions. Treat a
174
+ # failing bound test or required requirement edit as a contradiction signal: decide
175
+ # which promise reflects product intent and record that decision with propose-change
176
+ # (or --removal). Never weaken the test or add a requirement around the conflict;
177
+ # ask a human if intent is unclear.
155
178
  #
156
- # --why must be brief and actually explain why: whoever approves it should grasp
157
- # in one read the user need or outcome the feature serves. Longer is fine when it
158
- # earns its length; wordy is not. Avoid filler, and avoid describing HOW it works
159
- # instead of WHY anyone wants it.
160
- # good: "Staff scan the label to find a container; without a QR they key the ID by hand and mis-type it."
161
- # bad: "Ensures a high-quality experience across all surfaces." (filler)
162
- # --acceptance is the observable condition that proves it's done, not a
163
- # restatement of the title.
179
+ # Write the requirement as a product decision someone can approve, not a design
180
+ # task. It should sound like a capable teammate, not AI boilerplate.
181
+ # title: one product promise in present tense, not the implementation project
182
+ # why: one or two natural sentences naming the user need/problem and outcome
183
+ # acceptance: trigger or situation + observable result + material boundaries
184
+ # Use examples and tests to ground and double-check the general rule, not to replace
185
+ # it or ongoing conversation. Leave out components, data models, algorithms, endpoints,
186
+ # and UI controls unless they are themselves part of the user-visible contract.
187
+ # Include a price, quota, timeout, count, date,
188
+ # role, or fallback only when the source made it a real product decision; if an
189
+ # important policy is missing, ask instead of inventing a plausible value.
190
+ #
191
+ # good title: "Dispatchers see current shipment status"
192
+ # good why: "Dispatchers need to spot delayed shipments before they miss a delivery window."
193
+ # good acceptance: "When status changes, the dashboard shows it; when unavailable, it says so instead of showing stale data as current."
194
+ # bad: "Implement Redis polling every 30 seconds to ensure a seamless experience."
195
+ # (invented policy, implementation detail, and filler; no product outcome)
196
+ # Before saving, read it as a manager deciding scope: is the value clear, is the
197
+ # result observable, are decisions separated from guesses, and does every sentence
198
+ # earn its place?
164
199
  fp propose --project stowzilla \\
165
200
  --slug print_container_qr \\
166
201
  --name "Print QR code on container label" \\
@@ -182,6 +217,8 @@ module Fp
182
217
  # — the card, the thread, the issue. The requirement drops back to DRAFT and a
183
218
  # human reviews the old-vs-new diff before it counts again.
184
219
  fp propose-change print_container_qr --project stowzilla \\
220
+ --name "Print a scannable container QR" \\
221
+ --slug print_scannable_container_qr \\
185
222
  --why "Labels must survive a freezer" \\
186
223
  --acceptance "QR scans after 24h at -20C" \\
187
224
  --reason "Cold-chain rollout" \\
@@ -197,6 +234,25 @@ module Fp
197
234
  fp history print_container_qr --project stowzilla
198
235
  fp history print_container_qr --project stowzilla --pending
199
236
 
237
+ # Upload a file attachment to a requirement (screenshot evidence, docs, etc.)
238
+ # Supported formats: png, jpg, gif, webp, svg, pdf, txt, md (max 10 MB)
239
+ fp attach print_container_qr screenshot.png --project stowzilla
240
+
241
+ # List attachments for a requirement
242
+ fp attachments print_container_qr --project stowzilla
243
+
244
+ # Get a download URL for a specific attachment
245
+ fp attachments print_container_qr <attachment-id> --project stowzilla
246
+
247
+ # Delete an attachment
248
+ fp attach rm print_container_qr <attachment-id> --project stowzilla
249
+
250
+ # Read a Pari conversation so an agent can review AI suggestions.
251
+ # A conversation is just a record identified by its id — no --project
252
+ # needed, and it works for both project and account-level conversations.
253
+ # Use "Copy Reference" in Pari's header to get this command ready to paste.
254
+ fp conversation 03h0abc123xyz
255
+
200
256
  # Report evidence for a requirement
201
257
  fp report print_container_qr --project stowzilla \\
202
258
  --surface customer_android \\