featureparity 0.0.26 → 0.0.28
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 +3 -0
- data/lib/fp/client.rb +53 -2
- data/lib/fp/commands/analyze.rb +17 -0
- data/lib/fp/commands/base.rb +2 -0
- data/lib/fp/commands/comments.rb +146 -0
- data/lib/fp/commands/help.rb +48 -12
- data/lib/fp/commands/history.rb +12 -1
- data/lib/fp/commands/init.rb +35 -15
- data/lib/fp/commands/projects.rb +5 -1
- data/lib/fp/commands/propose.rb +34 -1
- data/lib/fp/commands/propose_change.rb +6 -6
- data/lib/fp/commands/report.rb +17 -1
- data/lib/fp/commands/show.rb +14 -3
- data/lib/fp/commands/spot_check.rb +458 -0
- data/lib/fp/commands/tutorial.rb +95 -0
- data/lib/fp/commands/whoami.rb +2 -0
- data/lib/fp/commands.rb +3 -0
- data/lib/fp/config.rb +12 -0
- data/lib/fp/junit.rb +14 -4
- data/lib/fp/tutorial/hexhaven/AGENTS.md +5 -0
- data/lib/fp/tutorial/hexhaven/README.md +29 -0
- data/lib/fp/tutorial/hexhaven/lib/hexhaven.rb +36 -0
- data/lib/fp/tutorial/hexhaven/test/junit_reporter.rb +134 -0
- data/lib/fp/tutorial/hexhaven/test/rules_test.rb +36 -0
- data/lib/fp/tutorial/hexhaven/test/test_helper.rb +6 -0
- data/lib/fp/version.rb +1 -1
- data/skills/fp/SKILL.md +27 -15
- data/skills/fp/references/cli.md +73 -0
- metadata +11 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dd6b0a540d21e123a2ceb43cb9f23005c218319866ff7e079b951d6288199bb1
|
|
4
|
+
data.tar.gz: 6e0d61e3a3d9e6f590c6e978b134736877b5abc8da2a9a80332ff6cd203dbf58
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fe1d8110555996bc72c885d502139e6f18dbef1d2ee8b8623eaf48c74dce6c40b33f46ffe4b42f09c9f92570380abab9b4dff77df3520d300ec6f43f35214890
|
|
7
|
+
data.tar.gz: 1f95d64e43e62a63411bd8cc665248777c3acdc58795e16552d4b5f671d3a1fb89a774bbf066e5f65f55bba7b716cc5f2fa78d51ea2b1a5afdc1fcd547acfa2d
|
data/lib/fp/cli.rb
CHANGED
|
@@ -7,6 +7,7 @@ module Fp
|
|
|
7
7
|
class CLI
|
|
8
8
|
COMMANDS = {
|
|
9
9
|
'init' => Commands::Init,
|
|
10
|
+
'tutorial' => Commands::Tutorial,
|
|
10
11
|
'projects' => Commands::Projects,
|
|
11
12
|
'surfaces' => Commands::Surfaces,
|
|
12
13
|
'showroom' => Commands::Showroom,
|
|
@@ -15,12 +16,14 @@ module Fp
|
|
|
15
16
|
'propose' => Commands::Propose,
|
|
16
17
|
'propose-change' => Commands::ProposeChange,
|
|
17
18
|
'comment' => Commands::Comment,
|
|
19
|
+
'comments' => Commands::Comments,
|
|
18
20
|
'history' => Commands::History,
|
|
19
21
|
'analyze' => Commands::Analyze,
|
|
20
22
|
'changes' => Commands::Changes,
|
|
21
23
|
'report' => Commands::Report,
|
|
22
24
|
'ci-report' => Commands::CiReport,
|
|
23
25
|
'changed-tests' => Commands::ChangedTests,
|
|
26
|
+
'spot-check' => Commands::SpotCheck,
|
|
24
27
|
'check' => Commands::Check,
|
|
25
28
|
'matrix' => Commands::Matrix,
|
|
26
29
|
'export' => Commands::Export,
|
data/lib/fp/client.rb
CHANGED
|
@@ -208,6 +208,12 @@ module Fp
|
|
|
208
208
|
post("/api/requirements/#{requirement_id}/analyze", body)
|
|
209
209
|
end
|
|
210
210
|
|
|
211
|
+
# POST /requirements/:id/test_review — Jev's read of whether these tests check what
|
|
212
|
+
# the requirement promises (`fp spot-check --jev`). Paid plans only (402 otherwise).
|
|
213
|
+
def review_spot_check_tests(requirement_id, tests)
|
|
214
|
+
post("/api/requirements/#{requirement_id}/test_review", { tests: tests })
|
|
215
|
+
end
|
|
216
|
+
|
|
211
217
|
# GET /requirements/:id/changes
|
|
212
218
|
#
|
|
213
219
|
# A requirement's change history: what it was, what changed, who changed it, and
|
|
@@ -241,6 +247,22 @@ module Fp
|
|
|
241
247
|
post("/api/requirements/#{requirement_id}/changes/#{change_id}/comment", params)
|
|
242
248
|
end
|
|
243
249
|
|
|
250
|
+
# GET /requirements/:id/comments
|
|
251
|
+
#
|
|
252
|
+
# The team's comments on a requirement, oldest first, plus who can be tagged:
|
|
253
|
+
# `mentionable` (people) and `mentionable_agents` (Fizzy #1494).
|
|
254
|
+
def list_requirement_comments(requirement_id)
|
|
255
|
+
get("/api/requirements/#{requirement_id}/comments")
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# POST /requirements/:id/comments
|
|
259
|
+
#
|
|
260
|
+
# Comment on a requirement. `mentions` are emails, `agent_mentions` agent key ids;
|
|
261
|
+
# tagged people are emailed and tagged agents fire the project's webhooks.
|
|
262
|
+
def comment_on_requirement(requirement_id, params)
|
|
263
|
+
post("/api/requirements/#{requirement_id}/comments", params)
|
|
264
|
+
end
|
|
265
|
+
|
|
244
266
|
# DELETE /requirements/:id/changes/:change_id
|
|
245
267
|
#
|
|
246
268
|
# Withdraw an open proposal. Only the proposal's author can withdraw it; the
|
|
@@ -513,7 +535,7 @@ module Fp
|
|
|
513
535
|
http.read_timeout = 30
|
|
514
536
|
|
|
515
537
|
response = http.request(request)
|
|
516
|
-
parse_response(response)
|
|
538
|
+
parse_response(response, uri)
|
|
517
539
|
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
518
540
|
{ ok: false, error: "Request timed out: #{e.message}", status: 0 }
|
|
519
541
|
rescue SocketError, Errno::ECONNREFUSED => e
|
|
@@ -522,13 +544,20 @@ module Fp
|
|
|
522
544
|
{ ok: false, error: "Request failed: #{e.message}", status: 0 }
|
|
523
545
|
end
|
|
524
546
|
|
|
525
|
-
def parse_response(response)
|
|
547
|
+
def parse_response(response, uri = nil)
|
|
526
548
|
body = response.body
|
|
527
549
|
status = response.code.to_i
|
|
528
550
|
|
|
529
551
|
# Try to parse as JSON
|
|
530
552
|
data = JSON.parse(body) rescue nil
|
|
531
553
|
|
|
554
|
+
# A success that isn't JSON is almost always the web app answering in the API's
|
|
555
|
+
# place — FP_API_URL set to the site rather than its api host — and every command
|
|
556
|
+
# used to crash on it with "undefined method '[]' for nil" (Fizzy #1446).
|
|
557
|
+
if status >= 200 && status < 300 && data.nil? && !body.to_s.strip.empty?
|
|
558
|
+
return { ok: false, status: status, error: not_the_api_message(uri) }
|
|
559
|
+
end
|
|
560
|
+
|
|
532
561
|
if status >= 200 && status < 300
|
|
533
562
|
{ ok: true, data: data, status: status }
|
|
534
563
|
else
|
|
@@ -544,5 +573,27 @@ module Fp
|
|
|
544
573
|
result
|
|
545
574
|
end
|
|
546
575
|
end
|
|
576
|
+
|
|
577
|
+
def not_the_api_message(uri)
|
|
578
|
+
origin = uri ? "#{uri.scheme}://#{uri.host}" : @base_url
|
|
579
|
+
message = +"#{origin} answered with a web page, not the FeatureParity API. " \
|
|
580
|
+
'Point fp at the API host instead (check it with `fp whoami`).'
|
|
581
|
+
suggestion = api_host_for(uri&.host)
|
|
582
|
+
message << " Did you mean https://#{suggestion}? (export FP_API_URL=https://#{suggestion})" if suggestion
|
|
583
|
+
message
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
# The API host that sits beside a web-app host: featureparity.dev → api.featureparity.dev,
|
|
587
|
+
# an ephemeral <env>.dev.featureparity.dev → api-<env>.dev.featureparity.dev.
|
|
588
|
+
def api_host_for(host)
|
|
589
|
+
return nil if host.nil? || host.start_with?('api.', 'api-')
|
|
590
|
+
return nil unless host.end_with?('featureparity.dev')
|
|
591
|
+
|
|
592
|
+
labels = host.split('.')
|
|
593
|
+
return "api.#{host}" if labels.length <= 2
|
|
594
|
+
return "api.#{host}" if labels.first == 'dev' || labels.first == 'staging'
|
|
595
|
+
|
|
596
|
+
"api-#{host}"
|
|
597
|
+
end
|
|
547
598
|
end
|
|
548
599
|
end
|
data/lib/fp/commands/analyze.rb
CHANGED
|
@@ -153,6 +153,7 @@ module Fp
|
|
|
153
153
|
'duplicate_of' => issues.find { |i| i['type'] == 'duplicate' }&.dig('duplicate_of'),
|
|
154
154
|
'conflicts' => (analysis && analysis['conflicts']) || [],
|
|
155
155
|
'suggestions' => (analysis && analysis['suggestions']) || [],
|
|
156
|
+
'warrants_requirement' => analysis && analysis['warrants_requirement'],
|
|
156
157
|
'error' => error
|
|
157
158
|
}
|
|
158
159
|
end
|
|
@@ -199,6 +200,8 @@ module Fp
|
|
|
199
200
|
end
|
|
200
201
|
puts " fails: #{entry['failed_checks'].join(', ')}" unless entry['failed_checks'].empty?
|
|
201
202
|
puts " issues: #{entry['issues'].map { |i| i['type'] }.join(', ')}" unless entry['issues'].empty?
|
|
203
|
+
warrants = warrants_line(entry['warrants_requirement'])
|
|
204
|
+
puts " #{warrants}" if warrants && entry['warrants_requirement']['judgment'] == false
|
|
202
205
|
puts " error: #{entry['error']}" if entry['error']
|
|
203
206
|
end
|
|
204
207
|
|
|
@@ -206,6 +209,8 @@ module Fp
|
|
|
206
209
|
puts "#{entry['slug']} — #{entry['title']}"
|
|
207
210
|
puts " verdict: #{entry['verdict'] || '(none)'} suggested: #{ACTIONS[entry['action']]}"
|
|
208
211
|
puts " superseded by: #{entry['duplicate_of']}" if entry['duplicate_of']
|
|
212
|
+
warrants = warrants_line(entry['warrants_requirement'])
|
|
213
|
+
puts " #{warrants}" if warrants
|
|
209
214
|
puts " fails: #{entry['failed_checks'].join(', ')}" unless entry['failed_checks'].empty?
|
|
210
215
|
entry['issues'].each { |i| puts " issue: #{i['type']}" }
|
|
211
216
|
entry['conflicts'].each do |c|
|
|
@@ -215,6 +220,18 @@ module Fp
|
|
|
215
220
|
puts " error: #{entry['error']}" if entry['error']
|
|
216
221
|
end
|
|
217
222
|
|
|
223
|
+
# The analyzer's yes/no on whether the concept deserves to be a requirement at all.
|
|
224
|
+
# A "no" carries its reason so a human can decide to withdraw it — the CLI only
|
|
225
|
+
# reports it; nothing is withdrawn or proposed on the strength of the judgment.
|
|
226
|
+
def warrants_line(warrants)
|
|
227
|
+
return nil unless warrants.is_a?(Hash) && [true, false].include?(warrants['judgment'])
|
|
228
|
+
|
|
229
|
+
line = "warrants a requirement: #{warrants['judgment'] ? 'yes' : 'no'}"
|
|
230
|
+
line += " (confidence #{(warrants['confidence'].to_f * 100).round}%)" unless warrants['confidence'].nil?
|
|
231
|
+
line += " — #{warrants['reason']}" if !warrants['judgment'] && !warrants['reason'].to_s.empty?
|
|
232
|
+
line
|
|
233
|
+
end
|
|
234
|
+
|
|
218
235
|
def progress(message)
|
|
219
236
|
warn message unless output.json?
|
|
220
237
|
end
|
data/lib/fp/commands/base.rb
CHANGED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
module Commands
|
|
5
|
+
# `fp comments <slug>` — the team's conversation on a requirement (Fizzy #1494).
|
|
6
|
+
#
|
|
7
|
+
# Without --body it prints the comments, oldest first. With --body it posts one, and
|
|
8
|
+
# --tag names who to tag: a teammate by email or name, or one of the team's agents by
|
|
9
|
+
# name or key name. Tagged people are emailed; each tagged agent fires the project's
|
|
10
|
+
# `requirement.agent_mentioned` webhooks, so whatever runs that agent can pick it up.
|
|
11
|
+
#
|
|
12
|
+
# Not to be confused with `fp comment`, which threads a comment onto a requirement's
|
|
13
|
+
# open change *proposal*.
|
|
14
|
+
class Comments < Base
|
|
15
|
+
KNOWN_FLAGS = { project: :string, body: :string, tag: :string }.freeze
|
|
16
|
+
|
|
17
|
+
help_summary "Read or post the team's comments on a requirement, tagging people or agents"
|
|
18
|
+
help_usage 'fp comments <slug> [--body "..." [--tag <who>,<who>]] --project <slug>'
|
|
19
|
+
help_flag :project, 'Project slug (falls back to .featureparity.yml)'
|
|
20
|
+
help_flag :body, 'Post this comment (omit to list the comments)'
|
|
21
|
+
help_flag :tag, 'Comma-separated people (email or name) or agents (name or key name) to tag'
|
|
22
|
+
help_example 'fp comments print_qr --project stowzilla'
|
|
23
|
+
help_example 'fp comments print_qr --body "The redesign dropped this screen — retire it?" --tag andy@example.com'
|
|
24
|
+
|
|
25
|
+
def run(args)
|
|
26
|
+
opts, positional = parse_flags(args, KNOWN_FLAGS)
|
|
27
|
+
require_project!(opts)
|
|
28
|
+
|
|
29
|
+
slug = positional.first
|
|
30
|
+
unless slug
|
|
31
|
+
output.error('Requirement slug is required. Usage: fp comments <slug> [--body "..."] [--project <project>]')
|
|
32
|
+
exit 1
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
project_id = resolve_project_id(opts[:project])
|
|
36
|
+
requirement = find_requirement!(project_id, slug)
|
|
37
|
+
listing = fetch!(client.list_requirement_comments(requirement['id']))
|
|
38
|
+
|
|
39
|
+
if opts[:body].nil?
|
|
40
|
+
print_comments(listing, slug)
|
|
41
|
+
else
|
|
42
|
+
post_comment(requirement, listing, opts, slug)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def post_comment(requirement, listing, opts, slug)
|
|
49
|
+
body = opts[:body].to_s
|
|
50
|
+
if body.strip.empty?
|
|
51
|
+
output.error('A comment needs some text. Pass --body "...".')
|
|
52
|
+
exit 1
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
people, agents = resolve_tags(opts[:tag], listing)
|
|
56
|
+
data = fetch!(client.comment_on_requirement(requirement['id'],
|
|
57
|
+
body: body,
|
|
58
|
+
mentions: people.map { |p| p['email'] },
|
|
59
|
+
agent_mentions: agents.map { |a| a['id'] }))
|
|
60
|
+
comment = data['comment'] || {}
|
|
61
|
+
|
|
62
|
+
output.success(data) do
|
|
63
|
+
puts "Commented on '#{slug}'."
|
|
64
|
+
puts
|
|
65
|
+
print_comment(comment)
|
|
66
|
+
tagged_people = Array(comment['mentions']).map { |m| m['name'] }
|
|
67
|
+
tagged_agents = Array(comment['agent_mentions']).map { |m| m['name'] }
|
|
68
|
+
puts "#{tagged_people.join(', ')} will get an email." unless tagged_people.empty?
|
|
69
|
+
puts "#{tagged_agents.join(', ')} will be notified through the project's webhooks." unless tagged_agents.empty?
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Each --tag matches exactly one person (email or name) or agent (name or key
|
|
74
|
+
# name), case-insensitively. Unknown or ambiguous tags stop the command rather
|
|
75
|
+
# than post a comment that silently tags nobody.
|
|
76
|
+
def resolve_tags(raw, listing)
|
|
77
|
+
people = []
|
|
78
|
+
agents = []
|
|
79
|
+
raw.to_s.split(',').map(&:strip).reject(&:empty?).each do |tag|
|
|
80
|
+
wanted = tag.delete_prefix('@').downcase
|
|
81
|
+
person_hits = Array(listing['mentionable']).select do |p|
|
|
82
|
+
[p['email'], p['name']].compact.map(&:downcase).include?(wanted)
|
|
83
|
+
end
|
|
84
|
+
agent_hits = Array(listing['mentionable_agents']).select do |a|
|
|
85
|
+
[a['name'], a['key_name']].compact.map(&:downcase).include?(wanted)
|
|
86
|
+
end
|
|
87
|
+
hits = person_hits + agent_hits
|
|
88
|
+
if hits.empty?
|
|
89
|
+
output.error("Nobody on this project's team matches '#{tag}'. Run `fp comments <slug>` to see who can be tagged.")
|
|
90
|
+
exit 1
|
|
91
|
+
elsif hits.size > 1
|
|
92
|
+
output.error("'#{tag}' matches more than one person or agent; use an email or key name instead.")
|
|
93
|
+
exit 1
|
|
94
|
+
end
|
|
95
|
+
person_hits.empty? ? agents << agent_hits.first : people << person_hits.first
|
|
96
|
+
end
|
|
97
|
+
[people, agents]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def print_comments(listing, slug)
|
|
101
|
+
comments = Array(listing['comments'])
|
|
102
|
+
output.success(listing) do
|
|
103
|
+
puts "Comments on '#{slug}'"
|
|
104
|
+
puts
|
|
105
|
+
if comments.empty?
|
|
106
|
+
puts ' (no comments yet)'
|
|
107
|
+
else
|
|
108
|
+
comments.each { |comment| print_comment(comment) }
|
|
109
|
+
end
|
|
110
|
+
print_taggable(listing)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def print_comment(comment)
|
|
115
|
+
kind = comment['actor_kind'] == 'agent' ? ' (agent)' : ''
|
|
116
|
+
puts "* #{comment['actor_name'] || comment['actor']}#{kind} at #{comment['recorded_at']}"
|
|
117
|
+
comment['body'].to_s.each_line { |line| puts " > #{line.chomp}" }
|
|
118
|
+
tags = Array(comment['mentions']) + Array(comment['agent_mentions'])
|
|
119
|
+
puts " tagged: #{tags.map { |t| t['name'] }.join(', ')}" unless tags.empty?
|
|
120
|
+
puts
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def print_taggable(listing)
|
|
124
|
+
people = Array(listing['mentionable']).map { |p| "#{p['name']} <#{p['email']}>" }
|
|
125
|
+
agents = Array(listing['mentionable_agents']).map { |a| "#{a['name']} (#{a['key_name']}, #{a['owner_name']}'s agent)" }
|
|
126
|
+
return if people.empty? && agents.empty?
|
|
127
|
+
|
|
128
|
+
puts 'Can be tagged with --tag:'
|
|
129
|
+
people.each { |p| puts " #{p}" }
|
|
130
|
+
agents.each { |a| puts " #{a}" }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def find_requirement!(project_id, slug)
|
|
134
|
+
fetch!(client.find_requirement_by_slug(project_id, slug))['requirement']
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def fetch!(result)
|
|
138
|
+
unless result[:ok]
|
|
139
|
+
output.error(result[:error], status: result[:status])
|
|
140
|
+
exit 1
|
|
141
|
+
end
|
|
142
|
+
result[:data]
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
data/lib/fp/commands/help.rb
CHANGED
|
@@ -39,6 +39,7 @@ module Fp
|
|
|
39
39
|
|
|
40
40
|
COMMANDS
|
|
41
41
|
init --project <slug> Onboard this repo: config, CI gate, agent docs
|
|
42
|
+
tutorial Write Hexhaven, a sample repo to practise on
|
|
42
43
|
projects List projects you have access to
|
|
43
44
|
projects show <slug> View a project and its surfaces
|
|
44
45
|
projects create <slug> --name ... Create a project (owned by your key's owner)
|
|
@@ -62,6 +63,8 @@ module Fp
|
|
|
62
63
|
propose-change <slug> --no-parent Propose un-nesting a child to top level
|
|
63
64
|
propose-change <slug> --withdraw Withdraw your own open proposal (to amend it)
|
|
64
65
|
comment <slug> --body "..." Comment on a requirement's open change proposal
|
|
66
|
+
comments <slug> Read the team's comments on a requirement
|
|
67
|
+
comments <slug> --body "..." [--tag who,...] Comment on a requirement, tagging people or agents
|
|
65
68
|
history <slug> --project <slug> Show a requirement's change history + diffs
|
|
66
69
|
analyze [<slug>] --project <slug> Triage requirement quality (read-only; never proposes)
|
|
67
70
|
changes [--source-url URL] [--github-pr] Show or publish work-item-linked activity
|
|
@@ -76,6 +79,7 @@ module Fp
|
|
|
76
79
|
ci-report --junit <file> --pr URL Ingest a PR's CI results as pr_passing — CI only
|
|
77
80
|
changed-tests List unresolved bound-test revision findings
|
|
78
81
|
changed-tests assess <id> ... Explain whether an exact revision still proves its requirement
|
|
82
|
+
spot-check [<slug>...] Check requirements' tests prove them (random, --count, --all, --test)
|
|
79
83
|
check Enforce parity; exits non-zero on violations
|
|
80
84
|
matrix --project <slug> Show the parity matrix
|
|
81
85
|
export --project <slug> Export all requirements as a Markdown document
|
|
@@ -159,6 +163,30 @@ module Fp
|
|
|
159
163
|
A stale or later candidate needs a new assessment. A PR candidate becomes accepted only
|
|
160
164
|
when a default-branch CI report later observes the same fingerprint.
|
|
161
165
|
|
|
166
|
+
SPOT CHECKS
|
|
167
|
+
A green receipt says a marked test ran and passed, not that it checks what the
|
|
168
|
+
requirement promises. `fp spot-check` picks an active requirement with evidence at
|
|
169
|
+
random, prints its acceptance beside the source of every locally marked test, and
|
|
170
|
+
gives the agent running it a brief: match each clause to an assertion, then break
|
|
171
|
+
the product code and confirm a test fails. Run it on a schedule or by hand.
|
|
172
|
+
|
|
173
|
+
fp spot-check # a random pick; prints the seed to repeat it
|
|
174
|
+
fp spot-check --count 5 --surface cli --json
|
|
175
|
+
fp spot-check print_container_qr print_label # chosen requirements
|
|
176
|
+
fp spot-check --all # every active requirement with evidence
|
|
177
|
+
fp spot-check --test test/qr_test.rb:42 # the test there, and its requirement
|
|
178
|
+
fp spot-check --count 3 --prompt | claude -p # hand it straight to an agent
|
|
179
|
+
fp spot-check --jev # add Jev's first read (paid plans)
|
|
180
|
+
|
|
181
|
+
`--prompt` prints the spot check as instructions for an agent, with what to report
|
|
182
|
+
back. `--jev` asks Jev, before the agent starts, whether each requirement's tests
|
|
183
|
+
appear to cover its acceptance, and which gap is likeliest. It's a hint for aiming
|
|
184
|
+
the probe, not a verdict. Jev is for paid plans: without one the spot check runs
|
|
185
|
+
without it.
|
|
186
|
+
|
|
187
|
+
It records nothing and never edits a test or a requirement. A test that stays
|
|
188
|
+
green when the promise is broken goes to a human.
|
|
189
|
+
|
|
162
190
|
AUTHENTICATION
|
|
163
191
|
Set FP_API_KEY environment variable, or use a profile:
|
|
164
192
|
|
|
@@ -289,24 +317,32 @@ module Fp
|
|
|
289
317
|
#
|
|
290
318
|
# Write the requirement as a product decision someone can approve, not a design
|
|
291
319
|
# task. It should sound like a capable teammate, not AI boilerplate.
|
|
292
|
-
# title: one product promise in present tense,
|
|
293
|
-
# why: one
|
|
294
|
-
# acceptance:
|
|
295
|
-
#
|
|
296
|
-
#
|
|
297
|
-
#
|
|
298
|
-
#
|
|
299
|
-
#
|
|
300
|
-
#
|
|
320
|
+
# title: one product promise in present tense, with a concrete subject and active verb
|
|
321
|
+
# why: one natural sentence naming the user need/problem and protected outcome
|
|
322
|
+
# acceptance: one independently verifiable rule of observable behaviour;
|
|
323
|
+
# append one concise concrete case as "Example: ..."
|
|
324
|
+
# Given/When/Then may clarify a rule, but is not mandatory. Split another result that
|
|
325
|
+
# could fail independently rather than comma-chaining it into the same requirement.
|
|
326
|
+
# Use the domain's real terms in examples and ask rather than invent consequential
|
|
327
|
+
# policy. Examples and tests ground and double-check the rule; they do not replace it.
|
|
328
|
+
# State each field directly. Avoid “This feature…”, “The goal is…”, “Users should be
|
|
329
|
+
# able to…”, “The system should…”, and task titles beginning “Enable” or “Implement”.
|
|
330
|
+
# Leave out components, data models, algorithms, endpoints, and UI controls unless
|
|
331
|
+
# they are themselves part of the user-visible contract.
|
|
332
|
+
# Include a price, quota, timeout, count, date, role, or fallback only when the source
|
|
333
|
+
# made it a real product decision; if important policy is missing, ask instead of
|
|
334
|
+
# inventing a plausible value.
|
|
301
335
|
#
|
|
302
336
|
# good title: "Dispatchers see current shipment status"
|
|
303
337
|
# good why: "Dispatchers need to spot delayed shipments before they miss a delivery window."
|
|
304
338
|
# good acceptance: "When status changes, the dashboard shows it; when unavailable, it says so instead of showing stale data as current."
|
|
305
339
|
# bad: "Implement Redis polling every 30 seconds to ensure a seamless experience."
|
|
306
340
|
# (invented policy, implementation detail, and filler; no product outcome)
|
|
307
|
-
# Before saving,
|
|
308
|
-
#
|
|
309
|
-
#
|
|
341
|
+
# Before saving, answer against the actual fields: who is this promise for, how would
|
|
342
|
+
# the concrete example prove it done, and why does the outcome matter? If an answer is
|
|
343
|
+
# vague, revise or ask. Then read it as a manager deciding scope: is the value clear,
|
|
344
|
+
# is the result observable, are decisions separated from guesses, and does every
|
|
345
|
+
# sentence earn its place? Do not save until the draft passes this review.
|
|
310
346
|
fp propose --project stowzilla \\
|
|
311
347
|
--slug print_container_qr \\
|
|
312
348
|
--name "Print QR code on container label" \\
|
data/lib/fp/commands/history.rb
CHANGED
|
@@ -31,7 +31,9 @@ module Fp
|
|
|
31
31
|
'removal_proposed' => 'deletion proposed',
|
|
32
32
|
'change_approved' => 'change approved',
|
|
33
33
|
'change_rejected' => 'change rejected',
|
|
34
|
-
'decision_recorded' => 'decision recorded'
|
|
34
|
+
'decision_recorded' => 'decision recorded',
|
|
35
|
+
'change_commented' => 'commented on the proposal',
|
|
36
|
+
'commented' => 'commented'
|
|
35
37
|
}.freeze
|
|
36
38
|
|
|
37
39
|
# A member's own stance on a requirement that already has a status (Fizzy #1478),
|
|
@@ -110,6 +112,7 @@ module Fp
|
|
|
110
112
|
puts "* #{label} — #{actor} (#{kind}) at #{format_time(change['recorded_at'])}"
|
|
111
113
|
puts " #{change['reason']}" if present?(change['reason'])
|
|
112
114
|
puts " note: #{change['note']}" if change['type'] == 'decision_recorded' && present?(change['note'])
|
|
115
|
+
print_comment(change) if %w[commented change_commented].include?(change['type'])
|
|
113
116
|
print_source(change['source'])
|
|
114
117
|
print_diff(change['changes'])
|
|
115
118
|
print_evidence_diff(change['evidence_diff'])
|
|
@@ -117,6 +120,14 @@ module Fp
|
|
|
117
120
|
puts
|
|
118
121
|
end
|
|
119
122
|
|
|
123
|
+
# The team's comments (Fizzy #1494) and comments on a proposal, so an agent reading
|
|
124
|
+
# the history sees the discussion, not just that one happened.
|
|
125
|
+
def print_comment(change)
|
|
126
|
+
change['body'].to_s.each_line { |line| puts " > #{line.chomp}" } if present?(change['body'])
|
|
127
|
+
mentions = Array(change['mentions'])
|
|
128
|
+
puts " tagged: #{mentions.join(', ')}" unless mentions.empty?
|
|
129
|
+
end
|
|
130
|
+
|
|
120
131
|
def print_source(source)
|
|
121
132
|
return if source.nil? || source.empty?
|
|
122
133
|
|
data/lib/fp/commands/init.rb
CHANGED
|
@@ -88,6 +88,15 @@ module Fp
|
|
|
88
88
|
report(project_slug, root, written, skipped, opts)
|
|
89
89
|
end
|
|
90
90
|
|
|
91
|
+
# The same agent brief `fp init` writes, for another command to put in a repo it
|
|
92
|
+
# creates (`fp tutorial`'s Hexhaven), so the two can never drift apart. Appends to
|
|
93
|
+
# an existing AGENTS.md/CLAUDE.md the way init does. Returns the paths written.
|
|
94
|
+
def self.write_agent_docs!(root, project_slug)
|
|
95
|
+
written = []
|
|
96
|
+
new.send(:write_agent_docs, root, project_slug, { dir: root }, written, [])
|
|
97
|
+
written.map(&:first)
|
|
98
|
+
end
|
|
99
|
+
|
|
91
100
|
private
|
|
92
101
|
|
|
93
102
|
def write_project_config(root, project_slug, level, opts, written, skipped)
|
|
@@ -470,18 +479,22 @@ module Fp
|
|
|
470
479
|
task.** It should read like a capable teammate wrote it: direct, specific, and free
|
|
471
480
|
of boilerplate such as “ensure a seamless experience.”
|
|
472
481
|
|
|
473
|
-
- **Title:** one clear product promise in present tense
|
|
474
|
-
capability, not the work (`“Dispatchers see
|
|
475
|
-
`“Implement status polling”`).
|
|
476
|
-
- **Why:** one
|
|
477
|
-
|
|
478
|
-
explain the mechanism.
|
|
479
|
-
- **Acceptance:**
|
|
480
|
-
the promise is true. State the trigger or situation, the result, and material
|
|
481
|
-
boundaries
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
482
|
+
- **Title:** one clear product promise in present tense, with a concrete subject and
|
|
483
|
+
active verb. Name the user-visible capability, not the work (`“Dispatchers see
|
|
484
|
+
current shipment status”`, not `“Implement status polling”`).
|
|
485
|
+
- **Why:** one natural sentence naming who needs this, the problem or risk, and the
|
|
486
|
+
outcome worth protecting. Do not repeat the title, narrate the request or bug
|
|
487
|
+
history, sell the feature, or explain the mechanism.
|
|
488
|
+
- **Acceptance:** one independently verifiable rule of observable behaviour that lets a
|
|
489
|
+
reviewer decide whether the promise is true. State the trigger or situation, the result, and material
|
|
490
|
+
boundaries that qualify that same promise. Given/When/Then may clarify those parts,
|
|
491
|
+
but it is not a mandatory template. Split a second result that could fail
|
|
492
|
+
independently into its own requirement instead of comma-chaining it here.
|
|
493
|
+
- **Example:** supply one concise concrete case that would pass the general rule, and
|
|
494
|
+
put it after the rule in Acceptance as `Example: ...`. Use the domain's real terms
|
|
495
|
+
and representative data, but ask rather than invent consequential policy. Do not mistake a few examples for the rule
|
|
496
|
+
or let marked tests stand for every case; neither replaces the general rule and
|
|
497
|
+
continued conversation.
|
|
485
498
|
- **Numbers and policy:** include a price, limit, timeout, date, role, or fallback only
|
|
486
499
|
when the source made it a real product decision. Never invent a plausible value to
|
|
487
500
|
fill a gap. If the decision matters and the source is silent, ask.
|
|
@@ -494,6 +507,10 @@ module Fp
|
|
|
494
507
|
unquestionable scope. Preserve important uncertainty as a question rather than
|
|
495
508
|
laundering a guess into acceptance criteria.
|
|
496
509
|
|
|
510
|
+
State the promise itself rather than commentary about it. Avoid openings such as “This
|
|
511
|
+
feature…”, “This requirement…”, “The goal is…”, “Users should be able to…”, and “The
|
|
512
|
+
system should…”. Use the real actor or product as the subject and an active verb.
|
|
513
|
+
|
|
497
514
|
- ✅ **Title:** `Dispatchers see current shipment status`
|
|
498
515
|
**Why:** `Dispatchers need to spot delayed shipments before they miss a delivery window.`
|
|
499
516
|
**Acceptance:** `When a shipment's status changes, the dashboard shows the new status; if status is unavailable, it says so instead of presenting stale data as current.`
|
|
@@ -502,9 +519,12 @@ module Fp
|
|
|
502
519
|
**Acceptance:** `Use a background worker and refresh every 30 seconds.`
|
|
503
520
|
This invents policy, prescribes a design, and never states the product outcome.
|
|
504
521
|
|
|
505
|
-
Before proposing,
|
|
506
|
-
|
|
507
|
-
|
|
522
|
+
Before proposing, answer three questions against the actual fields: Who is this promise
|
|
523
|
+
for? How would the concrete example prove it done? Why does the outcome matter? If any
|
|
524
|
+
answer is missing or vague, revise the draft or ask the user. Then read it as a manager deciding scope:
|
|
525
|
+
is the value clear, can the result be observed, are product decisions
|
|
526
|
+
distinguished from guesses, and does every sentence earn its place? If the draft fails
|
|
527
|
+
this review, revise it or ask before writing the record.
|
|
508
528
|
|
|
509
529
|
### 2. If the requirement is wrong, propose a change — don't silently edit it
|
|
510
530
|
|
data/lib/fp/commands/projects.rb
CHANGED
|
@@ -87,7 +87,11 @@ module Fp
|
|
|
87
87
|
output.success(result[:data]) do
|
|
88
88
|
if projects.empty?
|
|
89
89
|
puts 'No projects found.'
|
|
90
|
-
|
|
90
|
+
if (hint = config&.environment_mismatch_hint)
|
|
91
|
+
puts hint
|
|
92
|
+
else
|
|
93
|
+
puts 'Create one with: fp projects create <slug> --name "<Name>"'
|
|
94
|
+
end
|
|
91
95
|
else
|
|
92
96
|
headers = %w[SLUG NAME SURFACES]
|
|
93
97
|
rows = projects.map do |w|
|
data/lib/fp/commands/propose.rb
CHANGED
|
@@ -108,8 +108,10 @@ module Fp
|
|
|
108
108
|
end
|
|
109
109
|
|
|
110
110
|
requirement = result[:data]['requirement']
|
|
111
|
+
feedback = draft_feedback(requirement)
|
|
112
|
+
data = result[:data].merge('analysis' => feedback[:analysis], 'analysis_error' => feedback[:error]).compact
|
|
111
113
|
|
|
112
|
-
output.success(
|
|
114
|
+
output.success(data) do
|
|
113
115
|
puts "Created requirement '#{requirement['slug']}' as DRAFT."
|
|
114
116
|
puts
|
|
115
117
|
puts " Title: #{requirement['title']}"
|
|
@@ -125,11 +127,42 @@ module Fp
|
|
|
125
127
|
puts '⚠️ This requirement is a DRAFT.'
|
|
126
128
|
puts ' A human must activate it in the web app before it appears in the parity matrix.'
|
|
127
129
|
puts ' Agents cannot activate requirements.'
|
|
130
|
+
print_feedback(feedback, requirement['slug'])
|
|
128
131
|
end
|
|
129
132
|
end
|
|
130
133
|
|
|
131
134
|
private
|
|
132
135
|
|
|
136
|
+
# Quality feedback on the draft just created, as part of the same command, so an
|
|
137
|
+
# agent learns at once that its draft is weak or shouldn't exist and can fix or
|
|
138
|
+
# withdraw it before a human looks (requirement_analysis_runs_on_draft). It never
|
|
139
|
+
# undoes the draft: when analysis is unavailable the draft stands and we say so.
|
|
140
|
+
def draft_feedback(requirement)
|
|
141
|
+
result = client.analyze_requirement(requirement['id'])
|
|
142
|
+
return { analysis: result[:data]['analysis'] } if result[:ok] && result[:data].is_a?(Hash) && result[:data]['analysis']
|
|
143
|
+
|
|
144
|
+
{ error: result[:error] || 'no analysis came back' }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def print_feedback(feedback, slug)
|
|
148
|
+
puts
|
|
149
|
+
if feedback[:error]
|
|
150
|
+
puts "Quality feedback could not be produced: #{feedback[:error]}"
|
|
151
|
+
puts "The draft was still created. Try again later with `fp analyze #{slug}`."
|
|
152
|
+
return
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
analysis = feedback[:analysis]
|
|
156
|
+
puts "Quality feedback: #{analysis['verdict'] || 'no verdict'}"
|
|
157
|
+
Array(analysis['issues']).each do |issue|
|
|
158
|
+
puts " - #{issue['type'].to_s.tr('_', ' ')}"
|
|
159
|
+
end
|
|
160
|
+
Array(analysis['suggestions']).first(3).each { |suggestion| puts " → #{suggestion}" }
|
|
161
|
+
return unless %w[questionable needs_work].include?(analysis['verdict'])
|
|
162
|
+
|
|
163
|
+
puts "Revise it with `fp propose-change #{slug} ...`, or withdraw it if it shouldn't be a requirement."
|
|
164
|
+
end
|
|
165
|
+
|
|
133
166
|
# Parse a comma-separated --required-types list into a normalized array. Returns []
|
|
134
167
|
# when the flag is absent (meaning "any test counts / inherit project default").
|
|
135
168
|
#
|
|
@@ -23,16 +23,16 @@ module Fp
|
|
|
23
23
|
help_flag :parent, "Set the parent requirement's slug"
|
|
24
24
|
help_flag :no_parent, 'Propose un-nesting the requirement back to the top level (clears --parent)'
|
|
25
25
|
help_flag :slug, 'Proposed new slug (evidence follows; markers still need updating)'
|
|
26
|
-
help_flag :reason, 'What prompted this change
|
|
26
|
+
help_flag :reason, 'What prompted this change, in words (recorded on the change)'
|
|
27
27
|
help_flag :removal, 'Propose deleting the requirement instead of changing it'
|
|
28
28
|
help_flag :withdraw, 'Take back the open proposal you authored, restoring the requirement as it was'
|
|
29
|
-
help_flag :source_type, "
|
|
30
|
-
help_flag :source_url,
|
|
31
|
-
help_flag :source_context,
|
|
29
|
+
help_flag :source_type, "Where this proposal came from (recorded on the change; the requirement's origin is kept)"
|
|
30
|
+
help_flag :source_url, 'URL of the card/issue/thread that prompted this proposal'
|
|
31
|
+
help_flag :source_context, 'Free-form note about what prompted this proposal'
|
|
32
32
|
help_example 'fp propose-change print_qr --project stowzilla --acceptance "QR scans after 24h at -20C" --reason "Cold-chain rollout"'
|
|
33
33
|
help_example 'fp propose-change print_qr --removal --reason "Superseded by print_container_label"'
|
|
34
|
-
help_note "Applies the change and drops the requirement to draft for a human to review.
|
|
35
|
-
"
|
|
34
|
+
help_note "Applies the change and drops the requirement to draft for a human to review. --reason and\n" \
|
|
35
|
+
"--source-* record what prompted the proposal in its history; the requirement's own origin is unchanged."
|
|
36
36
|
|
|
37
37
|
KNOWN_FLAGS = {
|
|
38
38
|
project: :string, name: :string, why: :string, acceptance: :string,
|