featureparity 0.0.17 → 0.0.19
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/cli.rb +1 -0
- data/lib/fp/client.rb +44 -15
- data/lib/fp/commands/analyze.rb +213 -0
- data/lib/fp/commands/attach.rb +100 -136
- data/lib/fp/commands/attachments.rb +56 -83
- data/lib/fp/commands/help.rb +35 -7
- data/lib/fp/commands/report.rb +1 -1
- data/lib/fp/commands/setup.rb +1 -1
- data/lib/fp/commands.rb +1 -0
- data/lib/fp/version.rb +1 -1
- data/skills/fp/SKILL.md +35 -1
- data/skills/fp/references/cli.md +34 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dda702b723617dde0db1b5948be499c087f4c314a9ccd6a188d66bf512523328
|
|
4
|
+
data.tar.gz: 48104e563fe159fac04b247a51fd0780d060e87042e1416497bb33b5b9530b7f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a34620d3c364b92651707e89882943a7dd6abb22346192f4f3bce117471708c843bbc9a1606a661f66f4debcabed20fd1e109073ad3b1016aa386be7ffef36bc
|
|
7
|
+
data.tar.gz: a4647cc613c7aba590a4611fb20a7b1ba0d1ae2b4f70950cf1230092b40689721a86962a4d3b58ddb261e4b3f5c3fd29e84f2d096bba268cd79adf2704cd7aee
|
data/lib/fp/cli.rb
CHANGED
|
@@ -13,6 +13,7 @@ module Fp
|
|
|
13
13
|
'propose-change' => Commands::ProposeChange,
|
|
14
14
|
'comment' => Commands::Comment,
|
|
15
15
|
'history' => Commands::History,
|
|
16
|
+
'analyze' => Commands::Analyze,
|
|
16
17
|
'changes' => Commands::Changes,
|
|
17
18
|
'report' => Commands::Report,
|
|
18
19
|
'ci-report' => Commands::CiReport,
|
data/lib/fp/client.rb
CHANGED
|
@@ -106,6 +106,21 @@ module Fp
|
|
|
106
106
|
get("/api/projects/#{project_id}/requirement_changes", params)
|
|
107
107
|
end
|
|
108
108
|
|
|
109
|
+
# GET /requirements/:id/analysis — the cached analysis, free to read. `analysis` is
|
|
110
|
+
# null when none exists or the wording changed since it ran (`stale` says which).
|
|
111
|
+
def get_requirement_analysis(requirement_id)
|
|
112
|
+
get("/api/requirements/#{requirement_id}/analysis")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# POST /requirements/:id/analyze — run (or return the still-current cached) analysis.
|
|
116
|
+
# One model call per requirement, which is what keeps it inside the request timeout.
|
|
117
|
+
def analyze_requirement(requirement_id, force: false, include_conflicts: false)
|
|
118
|
+
body = {}
|
|
119
|
+
body[:force] = true if force
|
|
120
|
+
body[:include_conflicts] = true if include_conflicts
|
|
121
|
+
post("/api/requirements/#{requirement_id}/analyze", body)
|
|
122
|
+
end
|
|
123
|
+
|
|
109
124
|
# GET /requirements/:id/changes
|
|
110
125
|
#
|
|
111
126
|
# A requirement's change history: what it was, what changed, who changed it, and
|
|
@@ -274,15 +289,28 @@ module Fp
|
|
|
274
289
|
end
|
|
275
290
|
|
|
276
291
|
# POST /requirements/:requirement_id/attachments
|
|
277
|
-
# Confirm an upload and create the attachment record.
|
|
278
|
-
|
|
279
|
-
|
|
292
|
+
# Confirm an upload and create the attachment record. Visual evidence may also
|
|
293
|
+
# carry the surface, capture time, observed deployment, and originating test run.
|
|
294
|
+
# evidence_key is a direct join to the evidence receipt (surface:repo:test_type).
|
|
295
|
+
def create_attachment(requirement_id, attachment_id:, s3_key:, filename:, content_type:, size_bytes:,
|
|
296
|
+
surface: nil, captured_at: nil, deployed_at: nil, deployment_url: nil,
|
|
297
|
+
test_run_url: nil, sha: nil, example_title: nil, evidence_key: nil)
|
|
298
|
+
body = {
|
|
280
299
|
attachment_id: attachment_id,
|
|
281
300
|
s3_key: s3_key,
|
|
282
301
|
filename: filename,
|
|
283
302
|
content_type: content_type,
|
|
284
303
|
size_bytes: size_bytes
|
|
285
|
-
}
|
|
304
|
+
}
|
|
305
|
+
body[:surface] = surface if surface
|
|
306
|
+
body[:captured_at] = captured_at if captured_at
|
|
307
|
+
body[:deployed_at] = deployed_at if deployed_at
|
|
308
|
+
body[:deployment_url] = deployment_url if deployment_url
|
|
309
|
+
body[:test_run_url] = test_run_url if test_run_url
|
|
310
|
+
body[:sha] = sha if sha
|
|
311
|
+
body[:example_title] = example_title if example_title
|
|
312
|
+
body[:evidence_key] = evidence_key if evidence_key
|
|
313
|
+
post("/api/requirements/#{requirement_id}/attachments", body)
|
|
286
314
|
end
|
|
287
315
|
|
|
288
316
|
# GET /requirements/:requirement_id/attachments/:id
|
|
@@ -297,23 +325,24 @@ module Fp
|
|
|
297
325
|
delete("/api/requirements/#{requirement_id}/attachments/#{attachment_id}")
|
|
298
326
|
end
|
|
299
327
|
|
|
300
|
-
# Upload
|
|
301
|
-
#
|
|
328
|
+
# Upload to S3 using a streaming request. Video evidence can be up to 100 MB;
|
|
329
|
+
# reading it all into the CLI process would needlessly double memory usage.
|
|
302
330
|
def upload_to_presigned_url(url, file_path, content_type)
|
|
303
331
|
uri = URI.parse(url)
|
|
304
|
-
|
|
332
|
+
file_size = File.size(file_path)
|
|
305
333
|
|
|
306
334
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
307
335
|
http.use_ssl = uri.scheme == 'https'
|
|
308
336
|
http.open_timeout = 30
|
|
309
|
-
http.read_timeout = 120
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
337
|
+
http.read_timeout = 120
|
|
338
|
+
|
|
339
|
+
response = File.open(file_path, 'rb') do |file|
|
|
340
|
+
request = Net::HTTP::Put.new(uri)
|
|
341
|
+
request.body_stream = file
|
|
342
|
+
request['Content-Type'] = content_type
|
|
343
|
+
request['Content-Length'] = file_size.to_s
|
|
344
|
+
http.request(request)
|
|
345
|
+
end
|
|
317
346
|
status = response.code.to_i
|
|
318
347
|
|
|
319
348
|
if status >= 200 && status < 300
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
module Commands
|
|
5
|
+
# `fp analyze [<slug>]` — a read-only triage sweep of requirement quality.
|
|
6
|
+
#
|
|
7
|
+
# Analysis is one model call per requirement, so this walks the catalog client-side,
|
|
8
|
+
# one request at a time, rather than asking the server to do it all in a single
|
|
9
|
+
# request (which cannot finish inside the API timeout on a real catalog). Results are
|
|
10
|
+
# cached server-side, so a requirement whose wording hasn't changed is read back for
|
|
11
|
+
# free and only new or edited ones cost a call — re-running the sweep is cheap.
|
|
12
|
+
#
|
|
13
|
+
# It never files anything. Proposing a change demotes the requirement to draft and
|
|
14
|
+
# marks its evidence suspect, so sweeping a catalog and proposing on every flag could
|
|
15
|
+
# put dozens of requirements up for review at once. The report says what *kind* of
|
|
16
|
+
# follow-up each finding suggests; a human picks which proposals get filed.
|
|
17
|
+
class Analyze < Base
|
|
18
|
+
KNOWN_FLAGS = {
|
|
19
|
+
project: :string,
|
|
20
|
+
status: :string,
|
|
21
|
+
conflicts: :boolean,
|
|
22
|
+
force: :boolean,
|
|
23
|
+
cached: :boolean
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
# Server-side findings that mean "this probably shouldn't be its own row".
|
|
27
|
+
REMOVAL_CHECKS = %w[is_focused_promise].freeze
|
|
28
|
+
|
|
29
|
+
ACTIONS = {
|
|
30
|
+
'conflict' => 'Stop and ask a human — two promises may contradict',
|
|
31
|
+
'propose_removal' => 'Consider `fp propose-change <slug> --removal` (duplicate, or a fragment of a broader promise)',
|
|
32
|
+
'needs_human' => 'Not clearly a product promise — a human decides whether it belongs',
|
|
33
|
+
'propose_change' => 'Consider `fp propose-change <slug>` to fix the wording',
|
|
34
|
+
'in_review' => 'A change is already awaiting review — skipped',
|
|
35
|
+
'not_analyzed' => 'No current analysis (run without --cached to analyze)',
|
|
36
|
+
'error' => 'Analysis failed',
|
|
37
|
+
'ok' => 'Nothing to act on'
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
ORDER = %w[conflict propose_removal needs_human propose_change in_review not_analyzed error ok].freeze
|
|
41
|
+
|
|
42
|
+
def run(args)
|
|
43
|
+
opts, positional = parse_flags(args, KNOWN_FLAGS)
|
|
44
|
+
require_project!(opts)
|
|
45
|
+
|
|
46
|
+
project_id = resolve_project_id(opts[:project])
|
|
47
|
+
requirements = select_requirements(project_id, positional.first, opts)
|
|
48
|
+
|
|
49
|
+
entries = requirements.map { |r| analyze_one(r, opts, requirements.length > 1) }
|
|
50
|
+
report = build_report(entries)
|
|
51
|
+
|
|
52
|
+
output.success(report) { print_report(report) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def select_requirements(project_id, slug, opts)
|
|
58
|
+
result = client.list_requirements(project_id: project_id)
|
|
59
|
+
unless result[:ok]
|
|
60
|
+
output.error(result[:error], status: result[:status])
|
|
61
|
+
exit 1
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
all = result[:data]['requirements'] || []
|
|
65
|
+
if slug
|
|
66
|
+
found = all.select { |r| r['slug'] == slug }
|
|
67
|
+
if found.empty?
|
|
68
|
+
output.error("Requirement '#{slug}' not found in project")
|
|
69
|
+
exit 1
|
|
70
|
+
end
|
|
71
|
+
return found
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
statuses = (opts[:status] || 'active,draft').split(',').map(&:strip)
|
|
75
|
+
all.select { |r| statuses.include?(r['status']) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def analyze_one(requirement, opts, sweeping)
|
|
79
|
+
slug = requirement['slug']
|
|
80
|
+
if requirement['pending_change_kind'].to_s != ''
|
|
81
|
+
return entry(requirement, 'in_review', nil)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
progress("#{slug} ...") if sweeping
|
|
85
|
+
analysis = cached_analysis(requirement)
|
|
86
|
+
|
|
87
|
+
if analysis.nil? || opts[:force] || opts[:conflicts] && analysis['conflicts'].nil?
|
|
88
|
+
return entry(requirement, 'not_analyzed', nil) if opts[:cached]
|
|
89
|
+
|
|
90
|
+
result = client.analyze_requirement(requirement['id'], force: opts[:force],
|
|
91
|
+
include_conflicts: opts[:conflicts])
|
|
92
|
+
unless result[:ok]
|
|
93
|
+
# Without access (or without the analyzer) every call would fail the same way.
|
|
94
|
+
if [401, 402, 403, 503].include?(result[:status])
|
|
95
|
+
output.error(result[:error], status: result[:status])
|
|
96
|
+
exit 1
|
|
97
|
+
end
|
|
98
|
+
return entry(requirement, 'error', nil, error: result[:error])
|
|
99
|
+
end
|
|
100
|
+
analysis = result[:data]['analysis']
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
entry(requirement, classify(analysis), analysis)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Free read of whatever is already cached and current; nil if there is none.
|
|
107
|
+
def cached_analysis(requirement)
|
|
108
|
+
result = client.get_requirement_analysis(requirement['id'])
|
|
109
|
+
result[:ok] ? result[:data]['analysis'] : nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Which kind of follow-up a finding suggests. Precedence matters: a possible
|
|
113
|
+
# contradiction outranks everything because it is a product decision, not an edit.
|
|
114
|
+
def classify(analysis)
|
|
115
|
+
return 'conflict' unless (analysis['conflicts'] || []).empty?
|
|
116
|
+
|
|
117
|
+
issues = analysis['issues'] || []
|
|
118
|
+
failed = failed_legitimacy_checks(analysis)
|
|
119
|
+
|
|
120
|
+
return 'propose_removal' if issues.any? { |i| i['type'] == 'duplicate' } ||
|
|
121
|
+
(failed & REMOVAL_CHECKS).any?
|
|
122
|
+
return 'needs_human' if analysis['verdict'] == 'questionable'
|
|
123
|
+
return 'propose_change' if analysis['verdict'] == 'needs_work' || !issues.empty?
|
|
124
|
+
|
|
125
|
+
'ok'
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def failed_legitimacy_checks(analysis)
|
|
129
|
+
checks = analysis.dig('legitimacy', 'checks') || {}
|
|
130
|
+
checks.reject { |_name, c| c['passes'] }.keys
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def entry(requirement, action, analysis, error: nil)
|
|
134
|
+
issues = (analysis && analysis['issues']) || []
|
|
135
|
+
{
|
|
136
|
+
'slug' => requirement['slug'],
|
|
137
|
+
'title' => requirement['title'],
|
|
138
|
+
'status' => requirement['status'],
|
|
139
|
+
'action' => action,
|
|
140
|
+
'verdict' => analysis && analysis['verdict'],
|
|
141
|
+
'failed_checks' => analysis ? failed_legitimacy_checks(analysis) : [],
|
|
142
|
+
'issues' => issues,
|
|
143
|
+
'duplicate_of' => issues.find { |i| i['type'] == 'duplicate' }&.dig('duplicate_of'),
|
|
144
|
+
'conflicts' => (analysis && analysis['conflicts']) || [],
|
|
145
|
+
'suggestions' => (analysis && analysis['suggestions']) || [],
|
|
146
|
+
'error' => error
|
|
147
|
+
}
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def build_report(entries)
|
|
151
|
+
counts = entries.group_by { |e| e['action'] }.transform_values(&:length)
|
|
152
|
+
{ 'analyzed' => entries.length, 'counts' => counts, 'requirements' => entries }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def print_report(report)
|
|
156
|
+
entries = report['requirements']
|
|
157
|
+
if entries.empty?
|
|
158
|
+
puts 'No requirements to analyze.'
|
|
159
|
+
return
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
if entries.length == 1
|
|
163
|
+
print_detail(entries.first)
|
|
164
|
+
return
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
puts "Analysis triage — #{entries.length} requirement(s). Nothing was proposed or changed."
|
|
168
|
+
puts
|
|
169
|
+
|
|
170
|
+
ORDER.each do |action|
|
|
171
|
+
group = entries.select { |e| e['action'] == action }
|
|
172
|
+
next if group.empty?
|
|
173
|
+
|
|
174
|
+
puts "#{action.tr('_', ' ').upcase} (#{group.length}) — #{ACTIONS[action]}"
|
|
175
|
+
next if action == 'ok'
|
|
176
|
+
|
|
177
|
+
group.each { |e| print_line(e) }
|
|
178
|
+
puts
|
|
179
|
+
end
|
|
180
|
+
puts
|
|
181
|
+
puts 'Report first; file only the proposals a human picks. Each proposal moves its requirement to draft.'
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def print_line(entry)
|
|
185
|
+
puts " #{entry['slug']} — #{truncate(entry['title'].to_s, 70)}"
|
|
186
|
+
puts " superseded by: #{entry['duplicate_of']}" if entry['duplicate_of']
|
|
187
|
+
entry['conflicts'].each do |c|
|
|
188
|
+
puts " conflicts with #{c['slug']} (#{(c['probability'].to_f * 100).round}%)"
|
|
189
|
+
end
|
|
190
|
+
puts " fails: #{entry['failed_checks'].join(', ')}" unless entry['failed_checks'].empty?
|
|
191
|
+
puts " issues: #{entry['issues'].map { |i| i['type'] }.join(', ')}" unless entry['issues'].empty?
|
|
192
|
+
puts " error: #{entry['error']}" if entry['error']
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def print_detail(entry)
|
|
196
|
+
puts "#{entry['slug']} — #{entry['title']}"
|
|
197
|
+
puts " verdict: #{entry['verdict'] || '(none)'} suggested: #{ACTIONS[entry['action']]}"
|
|
198
|
+
puts " superseded by: #{entry['duplicate_of']}" if entry['duplicate_of']
|
|
199
|
+
puts " fails: #{entry['failed_checks'].join(', ')}" unless entry['failed_checks'].empty?
|
|
200
|
+
entry['issues'].each { |i| puts " issue: #{i['type']}" }
|
|
201
|
+
entry['conflicts'].each do |c|
|
|
202
|
+
puts " conflict with #{c['slug']} (#{(c['probability'].to_f * 100).round}%): #{c['reason']}"
|
|
203
|
+
end
|
|
204
|
+
entry['suggestions'].each { |s| puts " - #{s}" }
|
|
205
|
+
puts " error: #{entry['error']}" if entry['error']
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def progress(message)
|
|
209
|
+
warn message unless output.json?
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
data/lib/fp/commands/attach.rb
CHANGED
|
@@ -4,186 +4,150 @@ require_relative 'base'
|
|
|
4
4
|
|
|
5
5
|
module Fp
|
|
6
6
|
module Commands
|
|
7
|
-
# Upload
|
|
8
|
-
#
|
|
9
|
-
# Usage:
|
|
10
|
-
# fp attach <slug> <file> Upload a file to a requirement
|
|
11
|
-
# fp attachments <slug> List attachments for a requirement
|
|
12
|
-
# fp attachment <slug> <id> Get download URL for an attachment
|
|
13
|
-
# fp attachment rm <slug> <id> Delete an attachment
|
|
7
|
+
# Upload visual test evidence or another supporting file to a requirement.
|
|
14
8
|
class Attach < Base
|
|
15
|
-
# Allowed content types and their file extensions
|
|
16
9
|
CONTENT_TYPES = {
|
|
17
|
-
'png' => 'image/png',
|
|
18
|
-
'
|
|
19
|
-
'
|
|
20
|
-
'
|
|
21
|
-
'
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
10
|
+
'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg',
|
|
11
|
+
'gif' => 'image/gif', 'webp' => 'image/webp', 'svg' => 'image/svg+xml',
|
|
12
|
+
'mp4' => 'video/mp4', 'webm' => 'video/webm', 'mov' => 'video/quicktime',
|
|
13
|
+
'pdf' => 'application/pdf', 'txt' => 'text/plain', 'md' => 'text/markdown',
|
|
14
|
+
'json' => 'application/json'
|
|
15
|
+
}.freeze
|
|
16
|
+
VISUAL_EXTENSIONS = %w[png jpg jpeg gif webp mp4 webm mov].freeze
|
|
17
|
+
MAX_FILE_SIZE = 100 * 1024 * 1024
|
|
18
|
+
KNOWN_FLAGS = {
|
|
19
|
+
project: :string,
|
|
20
|
+
surface: :string,
|
|
21
|
+
captured_at: :string,
|
|
22
|
+
deployed_at: :string,
|
|
23
|
+
deployment_url: :string,
|
|
24
|
+
test_run_url: :string,
|
|
25
|
+
sha: :string,
|
|
26
|
+
example_title: :string,
|
|
27
|
+
evidence_key: :string
|
|
26
28
|
}.freeze
|
|
27
|
-
|
|
28
|
-
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
|
|
29
29
|
|
|
30
30
|
def run(args)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
upload_attachment(args)
|
|
36
|
-
end
|
|
31
|
+
opts, positional = parse_flags(args, KNOWN_FLAGS)
|
|
32
|
+
return delete_attachment(opts, positional.drop(1)) if %w[rm delete].include?(positional.first)
|
|
33
|
+
|
|
34
|
+
upload_attachment(opts, positional)
|
|
37
35
|
end
|
|
38
36
|
|
|
39
37
|
private
|
|
40
38
|
|
|
41
|
-
def upload_attachment(
|
|
42
|
-
slug =
|
|
43
|
-
|
|
39
|
+
def upload_attachment(opts, positional)
|
|
40
|
+
slug, file_path = positional
|
|
41
|
+
return usage_error('Missing requirement slug') if slug.to_s.empty?
|
|
42
|
+
return usage_error('Missing file path') if file_path.to_s.empty?
|
|
43
|
+
return command_error("File not found: #{file_path}") unless File.file?(file_path)
|
|
44
44
|
|
|
45
|
-
if slug.nil? || slug.empty?
|
|
46
|
-
Output.error('Missing requirement slug. Usage: fp attach <slug> <file>')
|
|
47
|
-
return 1
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
if file_path.nil? || file_path.empty?
|
|
51
|
-
Output.error('Missing file path. Usage: fp attach <slug> <file>')
|
|
52
|
-
return 1
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
unless File.exist?(file_path)
|
|
56
|
-
Output.error("File not found: #{file_path}")
|
|
57
|
-
return 1
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
project_id = require_project_id!
|
|
61
|
-
return 1 unless project_id
|
|
62
|
-
|
|
63
|
-
# Validate file
|
|
64
45
|
filename = File.basename(file_path)
|
|
65
|
-
|
|
66
|
-
content_type = CONTENT_TYPES[
|
|
67
|
-
|
|
46
|
+
extension = File.extname(filename).delete('.').downcase
|
|
47
|
+
content_type = CONTENT_TYPES[extension]
|
|
68
48
|
unless content_type
|
|
69
|
-
|
|
70
|
-
Output.info("Allowed: #{CONTENT_TYPES.keys.join(', ')}")
|
|
71
|
-
return 1
|
|
49
|
+
return command_error("Unsupported file type: .#{extension}. Allowed: #{CONTENT_TYPES.keys.join(', ')}")
|
|
72
50
|
end
|
|
73
51
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
return
|
|
52
|
+
size = File.size(file_path)
|
|
53
|
+
return command_error("File is empty: #{file_path}") if size.zero?
|
|
54
|
+
if size > MAX_FILE_SIZE
|
|
55
|
+
return command_error("File too large: #{format_size(size)} (max #{format_size(MAX_FILE_SIZE)})")
|
|
78
56
|
end
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
result = client.find_requirement_by_slug(project_id, slug)
|
|
82
|
-
unless result[:ok]
|
|
83
|
-
Output.error(result[:error])
|
|
84
|
-
return 1
|
|
57
|
+
if VISUAL_EXTENSIONS.include?(extension) && opts[:surface].to_s.empty?
|
|
58
|
+
return command_error('--surface is required for screenshot and video evidence')
|
|
85
59
|
end
|
|
86
60
|
|
|
87
|
-
|
|
88
|
-
|
|
61
|
+
require_project!(opts)
|
|
62
|
+
project_id = resolve_project_id(opts[:project])
|
|
63
|
+
requirement_result = client.find_requirement_by_slug(project_id, slug)
|
|
64
|
+
return api_error(requirement_result) unless requirement_result[:ok]
|
|
89
65
|
|
|
90
|
-
|
|
91
|
-
Output.info("Requesting upload URL for #{filename}...")
|
|
66
|
+
requirement_id = requirement_result[:data].dig('requirement', 'id')
|
|
92
67
|
presign_result = client.presign_attachment(
|
|
93
68
|
requirement_id,
|
|
94
69
|
filename: filename,
|
|
95
70
|
content_type: content_type,
|
|
96
|
-
size_bytes:
|
|
71
|
+
size_bytes: size
|
|
97
72
|
)
|
|
73
|
+
return api_error(presign_result, prefix: 'Failed to get upload URL') unless presign_result[:ok]
|
|
98
74
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
presign_data = presign_result[:data]['presign']
|
|
105
|
-
upload_url = presign_data['upload_url']
|
|
106
|
-
attachment_id = presign_data['attachment_id']
|
|
107
|
-
s3_key = presign_data['s3_key']
|
|
108
|
-
|
|
109
|
-
# Step 2: Upload to S3
|
|
110
|
-
Output.info("Uploading #{filename} (#{format_size(file_size)})...")
|
|
111
|
-
upload_result = client.upload_to_presigned_url(upload_url, file_path, content_type)
|
|
112
|
-
|
|
113
|
-
unless upload_result[:ok]
|
|
114
|
-
Output.error("Upload failed: #{upload_result[:error]}")
|
|
115
|
-
return 1
|
|
116
|
-
end
|
|
75
|
+
presign = presign_result[:data]['presign']
|
|
76
|
+
upload_result = client.upload_to_presigned_url(presign['upload_url'], file_path, content_type)
|
|
77
|
+
return api_error(upload_result, prefix: 'Upload failed') unless upload_result[:ok]
|
|
117
78
|
|
|
118
|
-
# Step 3: Confirm upload
|
|
119
|
-
Output.info('Confirming upload...')
|
|
120
79
|
create_result = client.create_attachment(
|
|
121
80
|
requirement_id,
|
|
122
|
-
attachment_id: attachment_id,
|
|
123
|
-
s3_key: s3_key,
|
|
81
|
+
attachment_id: presign['attachment_id'],
|
|
82
|
+
s3_key: presign['s3_key'],
|
|
124
83
|
filename: filename,
|
|
125
84
|
content_type: content_type,
|
|
126
|
-
size_bytes:
|
|
85
|
+
size_bytes: size,
|
|
86
|
+
surface: opts[:surface],
|
|
87
|
+
captured_at: opts[:captured_at],
|
|
88
|
+
deployed_at: opts[:deployed_at],
|
|
89
|
+
deployment_url: opts[:deployment_url],
|
|
90
|
+
test_run_url: opts[:test_run_url],
|
|
91
|
+
sha: opts[:sha],
|
|
92
|
+
example_title: opts[:example_title],
|
|
93
|
+
evidence_key: opts[:evidence_key]
|
|
127
94
|
)
|
|
128
|
-
|
|
129
|
-
unless create_result[:ok]
|
|
130
|
-
Output.error("Failed to confirm upload: #{create_result[:error]}")
|
|
131
|
-
return 1
|
|
132
|
-
end
|
|
95
|
+
return api_error(create_result, prefix: 'Failed to confirm upload') unless create_result[:ok]
|
|
133
96
|
|
|
134
97
|
attachment = create_result[:data]['attachment']
|
|
135
|
-
|
|
136
|
-
|
|
98
|
+
output.success({ attachment: attachment }) do
|
|
99
|
+
puts "Uploaded #{filename} to requirement '#{slug}'."
|
|
100
|
+
puts " ID: #{attachment['id']}"
|
|
101
|
+
puts " Surface: #{attachment['surface']}" if attachment['surface']
|
|
102
|
+
puts " Test: #{attachment['example_title']}" if attachment['example_title']
|
|
103
|
+
puts " Evidence key: #{attachment['evidence_key']}" if attachment['evidence_key']
|
|
104
|
+
puts " Captured: #{attachment['captured_at']}" if attachment['captured_at']
|
|
105
|
+
puts " Deploy: #{attachment['deployment_url']}" if attachment['deployment_url']
|
|
106
|
+
puts ' Deployment context: unknown' if VISUAL_EXTENSIONS.include?(extension) && attachment['deployed_at'].to_s.empty?
|
|
107
|
+
end
|
|
137
108
|
0
|
|
138
109
|
end
|
|
139
110
|
|
|
140
|
-
def delete_attachment(
|
|
141
|
-
slug =
|
|
142
|
-
|
|
111
|
+
def delete_attachment(opts, positional)
|
|
112
|
+
slug, attachment_id = positional
|
|
113
|
+
return usage_error('Missing requirement slug', delete: true) if slug.to_s.empty?
|
|
114
|
+
return usage_error('Missing attachment ID', delete: true) if attachment_id.to_s.empty?
|
|
143
115
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
116
|
+
require_project!(opts)
|
|
117
|
+
project_id = resolve_project_id(opts[:project])
|
|
118
|
+
requirement_result = client.find_requirement_by_slug(project_id, slug)
|
|
119
|
+
return api_error(requirement_result) unless requirement_result[:ok]
|
|
148
120
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
project_id = require_project_id!
|
|
155
|
-
return 1 unless project_id
|
|
156
|
-
|
|
157
|
-
# Resolve requirement
|
|
158
|
-
result = client.find_requirement_by_slug(project_id, slug)
|
|
159
|
-
unless result[:ok]
|
|
160
|
-
Output.error(result[:error])
|
|
161
|
-
return 1
|
|
162
|
-
end
|
|
121
|
+
requirement_id = requirement_result[:data].dig('requirement', 'id')
|
|
122
|
+
result = client.delete_attachment(requirement_id, attachment_id)
|
|
123
|
+
return api_error(result, prefix: 'Failed to delete attachment') unless result[:ok]
|
|
163
124
|
|
|
164
|
-
|
|
165
|
-
|
|
125
|
+
output.success({ id: attachment_id, deleted: true }) { puts "Deleted attachment #{attachment_id}." }
|
|
126
|
+
0
|
|
127
|
+
end
|
|
166
128
|
|
|
167
|
-
|
|
168
|
-
|
|
129
|
+
def usage_error(message, delete: false)
|
|
130
|
+
usage = delete ? 'fp attach rm <slug> <id> [--project <project>]' :
|
|
131
|
+
'fp attach <slug> <file> [--surface <surface>] [--project <project>]'
|
|
132
|
+
command_error("#{message}. Usage: #{usage}")
|
|
133
|
+
end
|
|
169
134
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
135
|
+
def api_error(result, prefix: nil)
|
|
136
|
+
message = [prefix, result[:error]].compact.join(': ')
|
|
137
|
+
output.error(message, status: result[:status])
|
|
138
|
+
1
|
|
139
|
+
end
|
|
174
140
|
|
|
175
|
-
|
|
176
|
-
|
|
141
|
+
def command_error(message)
|
|
142
|
+
output.error(message)
|
|
143
|
+
1
|
|
177
144
|
end
|
|
178
145
|
|
|
179
146
|
def format_size(bytes)
|
|
180
|
-
if bytes >= 1024 * 1024
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
else
|
|
185
|
-
"#{bytes} bytes"
|
|
186
|
-
end
|
|
147
|
+
return format('%.1f MB', bytes.to_f / (1024 * 1024)) if bytes >= 1024 * 1024
|
|
148
|
+
return format('%.1f KB', bytes.to_f / 1024) if bytes >= 1024
|
|
149
|
+
|
|
150
|
+
"#{bytes} bytes"
|
|
187
151
|
end
|
|
188
152
|
end
|
|
189
153
|
end
|
|
@@ -4,118 +4,91 @@ require_relative 'base'
|
|
|
4
4
|
|
|
5
5
|
module Fp
|
|
6
6
|
module Commands
|
|
7
|
-
# List
|
|
8
|
-
#
|
|
9
|
-
# Usage:
|
|
10
|
-
# fp attachments <slug> List attachments for a requirement
|
|
11
|
-
# fp attachments <slug> <id> Get download URL for an attachment
|
|
7
|
+
# List a requirement's attachments, or get a short-lived URL for one.
|
|
12
8
|
class Attachments < Base
|
|
13
|
-
|
|
14
|
-
slug = args[0]
|
|
15
|
-
attachment_id = args[1]
|
|
16
|
-
|
|
17
|
-
if slug.nil? || slug.empty?
|
|
18
|
-
Output.error('Missing requirement slug. Usage: fp attachments <slug> [<id>]')
|
|
19
|
-
return 1
|
|
20
|
-
end
|
|
9
|
+
KNOWN_FLAGS = { project: :string }.freeze
|
|
21
10
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
unless result[:ok]
|
|
28
|
-
Output.error(result[:error])
|
|
11
|
+
def run(args)
|
|
12
|
+
opts, positional = parse_flags(args, KNOWN_FLAGS)
|
|
13
|
+
slug, attachment_id = positional
|
|
14
|
+
if slug.to_s.empty?
|
|
15
|
+
output.error('Missing requirement slug. Usage: fp attachments <slug> [<id>] [--project <project>]')
|
|
29
16
|
return 1
|
|
30
17
|
end
|
|
31
18
|
|
|
32
|
-
|
|
33
|
-
|
|
19
|
+
require_project!(opts)
|
|
20
|
+
project_id = resolve_project_id(opts[:project])
|
|
21
|
+
requirement_result = client.find_requirement_by_slug(project_id, slug)
|
|
22
|
+
return api_error(requirement_result) unless requirement_result[:ok]
|
|
34
23
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
else
|
|
38
|
-
list_attachments(requirement_id, slug)
|
|
39
|
-
end
|
|
24
|
+
requirement_id = requirement_result[:data].dig('requirement', 'id')
|
|
25
|
+
attachment_id ? show_attachment(requirement_id, attachment_id) : list_attachments(requirement_id, slug)
|
|
40
26
|
end
|
|
41
27
|
|
|
42
28
|
private
|
|
43
29
|
|
|
44
30
|
def list_attachments(requirement_id, slug)
|
|
45
31
|
result = client.list_attachments(requirement_id)
|
|
46
|
-
|
|
47
|
-
unless result[:ok]
|
|
48
|
-
Output.error(result[:error])
|
|
49
|
-
return 1
|
|
50
|
-
end
|
|
32
|
+
return api_error(result) unless result[:ok]
|
|
51
33
|
|
|
52
34
|
attachments = result[:data]['attachments'] || []
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
puts " Filename: #{att['filename']}"
|
|
68
|
-
puts " Type: #{att['content_type']}"
|
|
69
|
-
puts " Size: #{format_size(att['size_bytes'])}"
|
|
70
|
-
puts " Uploaded by: #{att['uploaded_by']}"
|
|
71
|
-
puts " Uploaded at: #{att['created_at']}"
|
|
72
|
-
puts
|
|
35
|
+
output.success({ attachments: attachments }) do
|
|
36
|
+
if attachments.empty?
|
|
37
|
+
puts "No attachments for requirement '#{slug}'."
|
|
38
|
+
else
|
|
39
|
+
puts "Attachments for '#{slug}':"
|
|
40
|
+
attachments.each do |attachment|
|
|
41
|
+
puts
|
|
42
|
+
puts " #{attachment['id']} #{attachment['filename']} (#{format_size(attachment['size_bytes'])})"
|
|
43
|
+
puts " Surface: #{attachment['surface']}" if attachment['surface']
|
|
44
|
+
puts " Captured: #{attachment['captured_at']}" if attachment['captured_at']
|
|
45
|
+
puts " Deployment: #{deployment_label(attachment)}"
|
|
46
|
+
puts " Test run: #{attachment['test_run_url']}" if attachment['test_run_url']
|
|
47
|
+
end
|
|
48
|
+
end
|
|
73
49
|
end
|
|
74
|
-
|
|
75
50
|
0
|
|
76
51
|
end
|
|
77
52
|
|
|
78
|
-
def show_attachment(requirement_id, attachment_id
|
|
53
|
+
def show_attachment(requirement_id, attachment_id)
|
|
79
54
|
result = client.get_attachment(requirement_id, attachment_id)
|
|
80
|
-
|
|
81
|
-
unless result[:ok]
|
|
82
|
-
Output.error(result[:error])
|
|
83
|
-
return 1
|
|
84
|
-
end
|
|
55
|
+
return api_error(result) unless result[:ok]
|
|
85
56
|
|
|
86
57
|
data = result[:data]['attachment_response'] || result[:data]
|
|
87
58
|
attachment = data['attachment']
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
59
|
+
output.success(data) do
|
|
60
|
+
puts "Attachment: #{attachment['filename']}"
|
|
61
|
+
puts " Type: #{attachment['content_type']}"
|
|
62
|
+
puts " Size: #{format_size(attachment['size_bytes'])}"
|
|
63
|
+
puts " Surface: #{attachment['surface']}" if attachment['surface']
|
|
64
|
+
puts " Captured: #{attachment['captured_at']}" if attachment['captured_at']
|
|
65
|
+
puts " Deployment: #{deployment_label(attachment)}"
|
|
66
|
+
puts
|
|
67
|
+
puts "View URL (expires in #{data['expires_in'].to_i / 60} minutes):"
|
|
68
|
+
puts data['download_url']
|
|
94
69
|
end
|
|
70
|
+
0
|
|
71
|
+
end
|
|
95
72
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
puts "Download URL (expires in #{expires_in / 60} minutes):"
|
|
104
|
-
puts download_url
|
|
73
|
+
def deployment_label(attachment)
|
|
74
|
+
case attachment['deployment_status']
|
|
75
|
+
when 'after_deployment' then "captured after deploy#{" at #{attachment['deployed_at']}" if attachment['deployed_at']}"
|
|
76
|
+
when 'predates_deployment' then "capture predates deploy#{" at #{attachment['deployed_at']}" if attachment['deployed_at']}"
|
|
77
|
+
else 'unknown'
|
|
78
|
+
end
|
|
79
|
+
end
|
|
105
80
|
|
|
106
|
-
|
|
81
|
+
def api_error(result)
|
|
82
|
+
output.error(result[:error], status: result[:status])
|
|
83
|
+
1
|
|
107
84
|
end
|
|
108
85
|
|
|
109
86
|
def format_size(bytes)
|
|
110
|
-
|
|
87
|
+
bytes = bytes.to_i
|
|
88
|
+
return format('%.1f MB', bytes.to_f / (1024 * 1024)) if bytes >= 1024 * 1024
|
|
89
|
+
return format('%.1f KB', bytes.to_f / 1024) if bytes >= 1024
|
|
111
90
|
|
|
112
|
-
|
|
113
|
-
format('%.1f MB', bytes.to_f / (1024 * 1024))
|
|
114
|
-
elsif bytes >= 1024
|
|
115
|
-
format('%.1f KB', bytes.to_f / 1024)
|
|
116
|
-
else
|
|
117
|
-
"#{bytes} bytes"
|
|
118
|
-
end
|
|
91
|
+
"#{bytes} bytes"
|
|
119
92
|
end
|
|
120
93
|
end
|
|
121
94
|
end
|
data/lib/fp/commands/help.rb
CHANGED
|
@@ -24,6 +24,7 @@ module Fp
|
|
|
24
24
|
propose-change <slug> --removal Propose deleting a requirement
|
|
25
25
|
comment <slug> --body "..." Comment on a requirement's open change proposal
|
|
26
26
|
history <slug> --project <slug> Show a requirement's change history + diffs
|
|
27
|
+
analyze [<slug>] --project <slug> Triage requirement quality (read-only; never proposes)
|
|
27
28
|
changes [--source-url URL] [--github-pr] Show or publish work-item-linked activity
|
|
28
29
|
attach <slug> <file> Upload a file attachment to a requirement
|
|
29
30
|
attachments <slug> List attachments for a requirement
|
|
@@ -111,9 +112,9 @@ module Fp
|
|
|
111
112
|
|
|
112
113
|
Or add a named profile manually:
|
|
113
114
|
|
|
114
|
-
fp profile add
|
|
115
|
-
fp --profile
|
|
116
|
-
FP_PROFILE=
|
|
115
|
+
fp profile add pari --api-key fp_...
|
|
116
|
+
fp --profile pari projects
|
|
117
|
+
FP_PROFILE=pari fp projects
|
|
117
118
|
|
|
118
119
|
If both FP_API_KEY and a profile are set, FP_API_KEY wins.
|
|
119
120
|
|
|
@@ -267,11 +268,38 @@ module Fp
|
|
|
267
268
|
fp history print_container_qr --project stowzilla
|
|
268
269
|
fp history print_container_qr --project stowzilla --pending
|
|
269
270
|
|
|
270
|
-
#
|
|
271
|
-
#
|
|
272
|
-
fp
|
|
271
|
+
# Triage the catalog's quality: duplicates, fragments, vague wording, conflicts.
|
|
272
|
+
# One model call per uncached requirement; unchanged ones are read back free.
|
|
273
|
+
fp analyze --project stowzilla
|
|
274
|
+
fp analyze print_container_qr --project stowzilla --conflicts
|
|
275
|
+
|
|
276
|
+
# Upload visual test evidence to a requirement. Images and videos require
|
|
277
|
+
# --surface; deployment fields let reviewers verify exactly what build the
|
|
278
|
+
# capture observed. --example-title names the individual test the media
|
|
279
|
+
# proves, so it shows next to that test's receipt instead of a surface-wide
|
|
280
|
+
# gallery. --evidence-key is a stronger join: it directly names the receipt
|
|
281
|
+
# by its composite key (surface:repo:test_type) so the match survives title
|
|
282
|
+
# renames.
|
|
283
|
+
#
|
|
284
|
+
# Visual formats: png, jpg, gif, webp, mp4, webm, mov
|
|
285
|
+
# Text formats: txt, md, json (render inline as code blocks for API evidence)
|
|
286
|
+
# Documents: svg, pdf (downloadable)
|
|
287
|
+
# Max 100 MB.
|
|
288
|
+
fp attach print_container_qr e2e-run.webm --project stowzilla \\
|
|
289
|
+
--surface customer_web \\
|
|
290
|
+
--example-title "prints a QR code onto the container label" \\
|
|
291
|
+
--captured-at 2026-10-15T01:42:00Z \\
|
|
292
|
+
--deployed-at 2026-10-15T01:30:00Z \\
|
|
293
|
+
--sha abc1234 \\
|
|
294
|
+
--deployment-url https://preview.example.test \\
|
|
295
|
+
--test-run-url https://github.com/stowzilla/app/actions/runs/123
|
|
296
|
+
|
|
297
|
+
# For API tests, attach the JSON response as inline evidence
|
|
298
|
+
fp attach api_returns_order_details response.json --project stowzilla \\
|
|
299
|
+
--surface api \\
|
|
300
|
+
--evidence-key "api:stowzilla/api:e2e"
|
|
273
301
|
|
|
274
|
-
# List attachments
|
|
302
|
+
# List attachments and their capture/deployment context
|
|
275
303
|
fp attachments print_container_qr --project stowzilla
|
|
276
304
|
|
|
277
305
|
# Get a download URL for a specific attachment
|
data/lib/fp/commands/report.rb
CHANGED
|
@@ -86,7 +86,7 @@ module Fp
|
|
|
86
86
|
params[:work_item_url] = opts[:work_item] if opts[:work_item]
|
|
87
87
|
params[:ci_url] = opts[:ci_url] if opts[:ci_url]
|
|
88
88
|
params[:title] = opts[:title] if opts[:title]
|
|
89
|
-
params[:line] = opts[:line] if opts[:line]
|
|
89
|
+
params[:line] = opts[:line].to_i if opts[:line]
|
|
90
90
|
|
|
91
91
|
# Try to report evidence
|
|
92
92
|
result = client.report_evidence(params)
|
data/lib/fp/commands/setup.rb
CHANGED
|
@@ -69,7 +69,7 @@ module Fp
|
|
|
69
69
|
domain = URI.parse(effective_url).host.sub(/^api\./, '') rescue 'featureparity.dev'
|
|
70
70
|
|
|
71
71
|
puts "Get your API key from: https://#{domain}"
|
|
72
|
-
puts "(
|
|
72
|
+
puts "(Account → Agents & API keys → Mint Key)"
|
|
73
73
|
puts
|
|
74
74
|
print "Paste your API key (starts with fp_): "
|
|
75
75
|
$stdout.flush
|
data/lib/fp/commands.rb
CHANGED
|
@@ -9,6 +9,7 @@ require_relative 'commands/propose'
|
|
|
9
9
|
require_relative 'commands/propose_change'
|
|
10
10
|
require_relative 'commands/comment'
|
|
11
11
|
require_relative 'commands/history'
|
|
12
|
+
require_relative 'commands/analyze'
|
|
12
13
|
require_relative 'commands/changes'
|
|
13
14
|
require_relative 'commands/report'
|
|
14
15
|
require_relative 'commands/ci_report'
|
data/lib/fp/version.rb
CHANGED
data/skills/fp/SKILL.md
CHANGED
|
@@ -37,7 +37,9 @@ echo $FP_API_KEY
|
|
|
37
37
|
fp profile list
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
If not set, ask the user for their API key or have them run `fp setup`.
|
|
40
|
+
If not set, ask the user for their API key or have them run `fp setup`. Keys are
|
|
41
|
+
minted under **Account → Agents & API keys** in the web app. One key works across
|
|
42
|
+
every project the user belongs to; never ask for a per-project key.
|
|
41
43
|
|
|
42
44
|
## Core workflows
|
|
43
45
|
|
|
@@ -350,6 +352,37 @@ FeatureParity section in `AGENTS.md`):
|
|
|
350
352
|
fp init --project <slug> --surface <surface>
|
|
351
353
|
```
|
|
352
354
|
|
|
355
|
+
### Triage requirement quality (`fp analyze`)
|
|
356
|
+
|
|
357
|
+
`fp analyze` checks how well requirements are written and whether they belong in the
|
|
358
|
+
catalog at all. It is **read-only: it never proposes anything.** Sweep first, report, and
|
|
359
|
+
only file the proposals a human picks — every proposal moves its requirement back to draft
|
|
360
|
+
and marks its evidence `suspect`, and a requirement holds one open proposal at a time, so
|
|
361
|
+
proposing on every flag could put dozens of requirements in review at once.
|
|
362
|
+
|
|
363
|
+
```bash
|
|
364
|
+
fp analyze --project stowzilla # sweep active + draft requirements
|
|
365
|
+
fp analyze print_container_qr --project stowzilla # one requirement, with suggestions
|
|
366
|
+
fp analyze --project stowzilla --conflicts # also check for contradictions (slower)
|
|
367
|
+
fp analyze --project stowzilla --cached # read saved results only, no model calls
|
|
368
|
+
fp analyze --project stowzilla --json
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
It goes one requirement at a time. Results are cached until the wording changes, so a
|
|
372
|
+
re-run only pays for requirements that are new or edited. Requirements with a change
|
|
373
|
+
already awaiting review are skipped.
|
|
374
|
+
|
|
375
|
+
Each finding suggests a kind of follow-up. Treat it as a signal that something is wrong,
|
|
376
|
+
not as the fix — you still write the proposal.
|
|
377
|
+
|
|
378
|
+
| Finding | Suggested follow-up |
|
|
379
|
+
|---------|---------------------|
|
|
380
|
+
| Possible conflict with another requirement | Stop and ask a human — a contradiction is a product decision. Read both requirements first; the analyzer says *that* they clash, not *why* |
|
|
381
|
+
| Duplicate (the report names `superseded by: <slug>`) | `fp propose-change <slug> --removal --reason "superseded by <other>"` |
|
|
382
|
+
| Fragment of a broader promise (`is_focused_promise` fails) | Fold it into the parent's acceptance: `fp propose-change <parent>` for the wording, then `--removal` for the fragment |
|
|
383
|
+
| Not a product promise / not observable | A human decides whether it belongs; don't guess |
|
|
384
|
+
| Missing why, vague acceptance, implementation detail, boilerplate | `fp propose-change <slug> --why "..." --acceptance "..."` |
|
|
385
|
+
|
|
353
386
|
## Quick reference
|
|
354
387
|
|
|
355
388
|
| Task | Command |
|
|
@@ -364,6 +397,7 @@ fp init --project <slug> --surface <surface>
|
|
|
364
397
|
| Propose a change | `fp propose-change <slug> --project X --why "..." --reason "..."` |
|
|
365
398
|
| Propose a deletion | `fp propose-change <slug> --project X --removal --reason "..."` |
|
|
366
399
|
| View change history | `fp history <slug> --project X` |
|
|
400
|
+
| Triage requirement quality | `fp analyze --project X` |
|
|
367
401
|
| View work-item activity | `fp changes --project X --source-url URL` |
|
|
368
402
|
| Enforce parity | `fp check --project X` |
|
|
369
403
|
| Gate on this run | `fp check --project X --surface Y --junit file.xml` |
|
data/skills/fp/references/cli.md
CHANGED
|
@@ -17,7 +17,9 @@ These work with any command:
|
|
|
17
17
|
|
|
18
18
|
### fp setup
|
|
19
19
|
|
|
20
|
-
Interactive first-run configuration. Validates API key and saves a profile.
|
|
20
|
+
Interactive first-run configuration. Validates API key and saves a profile. Mint a
|
|
21
|
+
key under **Account → Agents & API keys** in the web app; one key works across every
|
|
22
|
+
project you belong to.
|
|
21
23
|
|
|
22
24
|
```bash
|
|
23
25
|
fp setup # Interactive
|
|
@@ -276,6 +278,37 @@ the review decision where one was made. An open proposal is called out at the en
|
|
|
276
278
|
|
|
277
279
|
`--project` is optional when the repo has a `.featureparity.yml`.
|
|
278
280
|
|
|
281
|
+
### fp analyze
|
|
282
|
+
|
|
283
|
+
Triage requirement quality: duplicates, fragments of a broader promise, vague wording,
|
|
284
|
+
possible conflicts. **Read-only — it never files a proposal.** Report first; a human
|
|
285
|
+
picks which proposals to file (each moves its requirement to draft).
|
|
286
|
+
|
|
287
|
+
```bash
|
|
288
|
+
fp analyze --project stowzilla # active + draft requirements
|
|
289
|
+
fp analyze print_container_qr --project stowzilla # one requirement, with suggestions
|
|
290
|
+
fp analyze --project stowzilla --status active # narrow the sweep
|
|
291
|
+
fp analyze --project stowzilla --conflicts # also check contradictions (slower)
|
|
292
|
+
fp analyze --project stowzilla --force # re-run even if the result is current
|
|
293
|
+
fp analyze --project stowzilla --cached # saved results only, no model calls
|
|
294
|
+
fp analyze --project stowzilla --json
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Analysis costs one model call per requirement, so the sweep runs one request at a time
|
|
298
|
+
rather than asking the server to do the whole catalog at once (which cannot finish inside
|
|
299
|
+
the API timeout). Results are cached until a requirement's title, why, or acceptance
|
|
300
|
+
changes, so re-running only pays for new or edited requirements. A requirement with a
|
|
301
|
+
change awaiting review is skipped.
|
|
302
|
+
|
|
303
|
+
The report groups findings by suggested follow-up: **conflict** (ask a human),
|
|
304
|
+
**propose removal** (duplicate — with `superseded by: <slug>` — or a fragment of a broader
|
|
305
|
+
promise), **needs human** (not clearly a product promise), **propose change** (wording
|
|
306
|
+
issues), plus in-review, not-analyzed (`--cached` only), and error. `--conflicts` adds
|
|
307
|
+
the other requirement's slug, a probability, and both acceptance clauses side by side —
|
|
308
|
+
the analyzer flags the clash but cannot explain it.
|
|
309
|
+
|
|
310
|
+
`--project` is optional when the repo has a `.featureparity.yml`.
|
|
311
|
+
|
|
279
312
|
### fp changes
|
|
280
313
|
|
|
281
314
|
Show requirement activity prompted by a work item, rather than opening every requirement's
|
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.
|
|
4
|
+
version: 0.0.19
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stowzilla
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-17 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: net-http
|
|
@@ -94,6 +94,7 @@ files:
|
|
|
94
94
|
- lib/fp/cli.rb
|
|
95
95
|
- lib/fp/client.rb
|
|
96
96
|
- lib/fp/commands.rb
|
|
97
|
+
- lib/fp/commands/analyze.rb
|
|
97
98
|
- lib/fp/commands/attach.rb
|
|
98
99
|
- lib/fp/commands/attachments.rb
|
|
99
100
|
- lib/fp/commands/base.rb
|