featureparity 0.0.27 → 0.0.29

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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/lib/fp/cli.rb +14 -2
  3. data/lib/fp/client.rb +79 -2
  4. data/lib/fp/commands/analyze.rb +17 -0
  5. data/lib/fp/commands/base.rb +16 -0
  6. data/lib/fp/commands/check.rb +38 -4
  7. data/lib/fp/commands/ci_report.rb +2 -2
  8. data/lib/fp/commands/comment.rb +1 -1
  9. data/lib/fp/commands/comments.rb +146 -0
  10. data/lib/fp/commands/coverage.rb +149 -0
  11. data/lib/fp/commands/doctor.rb +228 -0
  12. data/lib/fp/commands/help.rb +41 -1
  13. data/lib/fp/commands/history.rb +16 -1
  14. data/lib/fp/commands/init.rb +181 -117
  15. data/lib/fp/commands/list.rb +58 -4
  16. data/lib/fp/commands/mark.rb +167 -0
  17. data/lib/fp/commands/projects.rb +6 -1
  18. data/lib/fp/commands/propose.rb +435 -53
  19. data/lib/fp/commands/propose_change.rb +13 -7
  20. data/lib/fp/commands/question.rb +137 -0
  21. data/lib/fp/commands/report.rb +40 -4
  22. data/lib/fp/commands/run.rb +147 -0
  23. data/lib/fp/commands/show.rb +41 -3
  24. data/lib/fp/commands/spot_check.rb +458 -0
  25. data/lib/fp/commands/tutorial.rb +95 -0
  26. data/lib/fp/commands/whoami.rb +2 -0
  27. data/lib/fp/commands.rb +8 -0
  28. data/lib/fp/config.rb +12 -0
  29. data/lib/fp/junit.rb +74 -11
  30. data/lib/fp/repo_detector.rb +488 -0
  31. data/lib/fp/requirement_lint.rb +105 -0
  32. data/lib/fp/rspec_json.rb +54 -0
  33. data/lib/fp/tutorial/hexhaven/AGENTS.md +5 -0
  34. data/lib/fp/tutorial/hexhaven/README.md +29 -0
  35. data/lib/fp/tutorial/hexhaven/lib/hexhaven.rb +36 -0
  36. data/lib/fp/tutorial/hexhaven/test/junit_reporter.rb +134 -0
  37. data/lib/fp/tutorial/hexhaven/test/rules_test.rb +36 -0
  38. data/lib/fp/tutorial/hexhaven/test/test_helper.rb +6 -0
  39. data/lib/fp/version.rb +1 -1
  40. data/lib/fp.rb +1 -0
  41. data/skills/fp/SKILL.md +244 -105
  42. data/skills/fp/references/cli.md +304 -3
  43. metadata +19 -2
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
4
+ module Commands
5
+ # fp coverage [--project X] [--surface api] [--all] [--json]
6
+ #
7
+ # Draft coverage: which draft requirements have marked tests or evidence on which
8
+ # surfaces, and which have none.
9
+ #
10
+ # The gate measures active requirements only (plus ones an open proposal demoted), so
11
+ # on a freshly onboarded project — where every requirement is still a draft — `fp
12
+ # check` says "Nothing to enforce yet" and `fp list --gaps` says "No gaps found". The
13
+ # agent that just proposed the catalog and marked the tests had no way to confirm its
14
+ # work, and nothing to hand the human but "trust me" (Fizzy #1786). This reads the
15
+ # parity report's `drafts` section, where the server resolves each draft's cells from
16
+ # evidence exactly as it would once the draft is active.
17
+ #
18
+ # It ends with a plain list of drafts that have no evidence on a required surface —
19
+ # the "requirements with no test" section an onboarding agent hands the human.
20
+ #
21
+ # Informational only. Drafts are reported, never gated; this command always exits 0
22
+ # on a successful read.
23
+ class Coverage < Base
24
+ help_summary 'Show which draft requirements have evidence on which surfaces (never gated)'
25
+ help_usage 'fp coverage [--project <slug>] [--surface X] [--all]'
26
+ help_flag :project, 'Project slug (falls back to .featureparity.yml)'
27
+ help_flag :surface, 'Limit to one surface'
28
+ help_flag :all, 'Also list the measured requirements (active, or under review) the gate checks'
29
+ help_example 'fp coverage', 'Drafts × surfaces, then the drafts with no evidence'
30
+ help_example 'fp coverage --all --json'
31
+ help_note "Drafts are not enforced: a human activates a draft before fp check gates on it.\n" \
32
+ 'A cell reads the same here as it will in the matrix once the draft is active.'
33
+
34
+ KNOWN_FLAGS = {
35
+ project: :string,
36
+ surface: :string,
37
+ all: :boolean
38
+ }.freeze
39
+
40
+ def run(args)
41
+ opts, = parse_flags(args, KNOWN_FLAGS)
42
+ project_config = ProjectConfig.discover
43
+ project_slug = require_project!(opts, project_config)
44
+ project_id = resolve_project_id(project_slug)
45
+ surface = opts[:surface] || project_config.surface
46
+
47
+ result = client.get_parity_with_drafts(project_id, surface: surface)
48
+ unless result[:ok]
49
+ output.error(result[:error], status: result[:status])
50
+ exit 1
51
+ end
52
+
53
+ report = result[:data]['parity'] || {}
54
+ unless report.key?('drafts')
55
+ output.error('This FeatureParity API does not report draft coverage yet (it predates ' \
56
+ '`include_drafts`). Nothing was checked.')
57
+ exit 1
58
+ end
59
+
60
+ render(report, project_slug, surface, all: opts[:all])
61
+ end
62
+
63
+ # Drafts with no evidence on at least one required surface, as
64
+ # [{ 'slug', 'title', 'surfaces' => [...] }]. "No evidence" is the `missing` state:
65
+ # nothing — not even a stub or a red test — is filed for that surface.
66
+ def self.untested(drafts)
67
+ Array(drafts).filter_map do |row|
68
+ bare = Array(row['cells']).select { |c| c['state'] == 'missing' }.map { |c| c['surface'] }
69
+ next if bare.empty?
70
+
71
+ { 'slug' => row['slug'], 'title' => row['title'], 'surfaces' => bare }
72
+ end
73
+ end
74
+
75
+ # The drafts × surfaces table plus the "no evidence" list, for text output. Shared
76
+ # with `fp check --include-drafts`, so the two can't describe drafts differently.
77
+ def self.print_drafts(output, rows, surfaces, show_status: false)
78
+ if rows.empty?
79
+ puts 'No draft requirements.'
80
+ return
81
+ end
82
+
83
+ headers = %w[SLUG]
84
+ headers << 'STATUS' if show_status
85
+ headers += surfaces
86
+ table = rows.map do |row|
87
+ by_surface = Array(row['cells']).to_h { |c| [c['surface'], c['state']] }
88
+ cells = surfaces.map { |key| by_surface.key?(key) ? Parity.glyph(by_surface[key]) : Parity::NOT_REQUIRED }
89
+ line = [row['slug']]
90
+ line << row['status'] if show_status
91
+ line + cells
92
+ end
93
+ output.table(headers, table)
94
+ puts Parity::LEGEND
95
+ end
96
+
97
+ def self.print_untested(untested)
98
+ puts
99
+ if untested.empty?
100
+ puts 'Every draft has evidence on each of its required surfaces.'
101
+ return
102
+ end
103
+
104
+ puts "#{untested.size} draft(s) with no evidence on a required surface:"
105
+ untested.each { |u| puts " #{u['slug']} — no evidence on: #{u['surfaces'].join(', ')}" }
106
+ end
107
+
108
+ private
109
+
110
+ def render(report, project_slug, surface, all:)
111
+ drafts = report['drafts'] || []
112
+ surfaces = report['surfaces'] || []
113
+ untested = self.class.untested(drafts)
114
+
115
+ payload = {
116
+ 'project' => project_slug,
117
+ 'surface' => surface,
118
+ 'surfaces' => surfaces,
119
+ 'drafts' => drafts,
120
+ 'untested' => untested
121
+ }
122
+ payload['measured'] = report['requirements'] || [] if all
123
+
124
+ output.success(payload) do
125
+ scope = surface ? " (surface: #{surface})" : ''
126
+ puts "Draft coverage: #{project_slug}#{scope}"
127
+ puts
128
+
129
+ if all
130
+ measured = report['requirements'] || []
131
+ puts "Measured by the gate (#{measured.size}):"
132
+ if measured.empty?
133
+ puts 'None — no requirement is active yet.'
134
+ else
135
+ self.class.print_drafts(output, measured, surfaces, show_status: true)
136
+ end
137
+ puts
138
+ puts "Drafts (#{drafts.size}):"
139
+ end
140
+
141
+ self.class.print_drafts(output, drafts, surfaces)
142
+ self.class.print_untested(untested) unless drafts.empty?
143
+ puts
144
+ puts 'Drafts are reported, never gated. A human activates a draft before fp check enforces it.'
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rbconfig'
4
+
5
+ module Fp
6
+ module Commands
7
+ # fp doctor [--dir PATH] [--json]
8
+ #
9
+ # Says whether fp will work here, and what this repo needs before its tests can report
10
+ # (Fizzy #1786). Purely local: no API calls, no key needed.
11
+ #
12
+ # Ruby which Ruby is running fp, where the gem lives, and whether this directory
13
+ # pins a different Ruby (.ruby-version, .tool-versions, mise.toml). A
14
+ # version-manager shim resolves `fp` against the *pinned* Ruby, so a gem
15
+ # installed under another one fails inside the repo with
16
+ # `mise ERROR No version is set for shim: fp` and works from /tmp.
17
+ # Config the API environment (warning off production), whether a key resolved,
18
+ # and which .featureparity.yml applies.
19
+ # Repo RepoDetector's summary: test runners and the command that makes each emit
20
+ # a report, suggested surfaces, database and devcontainer.
21
+ #
22
+ # The shim error itself happens before fp starts, so doctor can only catch it when fp
23
+ # is reached some other way (another Ruby's bin dir, from /tmp). The skill and the
24
+ # generated AGENTS.md section describe that case for the agent that hits it.
25
+ class Doctor < Base
26
+ help_summary 'Check that fp works here: Ruby and version-manager pins, config, and what the repo needs'
27
+ help_usage 'fp doctor [--dir PATH]'
28
+ help_flag :dir, 'Directory to check (defaults to the working directory)'
29
+ help_example 'fp doctor'
30
+ help_example 'fp doctor --json', 'Machine-readable, for an agent to act on'
31
+ help_note "Purely local; makes no API calls, so it works without a key.\n" \
32
+ "If `fp` itself fails with `mise ERROR No version is set for shim: fp`, the shim stopped it\n" \
33
+ 'before fp could run: install the gem under the pinned Ruby (`gem install featureparity` in the repo).'
34
+
35
+ KNOWN_FLAGS = { dir: :string }.freeze
36
+
37
+ # Nearest file wins, and within one directory mise's own precedence applies.
38
+ PIN_FILES = %w[mise.toml .mise.toml .tool-versions .ruby-version].freeze
39
+
40
+ def run(args)
41
+ opts, = parse_flags(args, KNOWN_FLAGS)
42
+ dir = File.expand_path(opts[:dir] || Dir.pwd)
43
+ unless File.directory?(dir)
44
+ output.error("Not a directory: #{dir}")
45
+ exit 1
46
+ end
47
+
48
+ report = {
49
+ ruby: ruby_report(dir),
50
+ config: config_report(dir),
51
+ repo: RepoDetector.new(repo_root(dir)).detect
52
+ }
53
+ report[:agents_section] = agents_section_report(repo_root(dir))
54
+
55
+ output.success(report) { print_report(report) }
56
+ end
57
+
58
+ # The nearest Ruby pin at or above dir: { version:, file:, manager: } or nil.
59
+ def self.ruby_pin(dir)
60
+ current = File.expand_path(dir)
61
+ loop do
62
+ PIN_FILES.each do |name|
63
+ file = File.join(current, name)
64
+ next unless File.file?(file)
65
+
66
+ version = parse_pin(file)
67
+ return { version: version, file: file, manager: manager_for(name) } if version
68
+ end
69
+ parent = File.dirname(current)
70
+ return nil if parent == current
71
+
72
+ current = parent
73
+ end
74
+ end
75
+
76
+ def self.parse_pin(file)
77
+ text = File.read(file)
78
+ raw = case File.basename(file)
79
+ when '.ruby-version' then text.lines.first.to_s.strip
80
+ when '.tool-versions' then text[/^\s*ruby\s+(\S+)/, 1]
81
+ else text[/^\s*ruby\s*=\s*(?:\[\s*)?(?:\{[^}]*?version\s*=\s*)?["']([^"']+)["']/, 1]
82
+ end
83
+ raw = raw.to_s.sub(/\Aruby-/, '').strip
84
+ raw.empty? ? nil : raw
85
+ rescue SystemCallError
86
+ nil
87
+ end
88
+
89
+ def self.manager_for(name)
90
+ case name
91
+ when 'mise.toml', '.mise.toml' then 'mise'
92
+ when '.tool-versions' then 'mise or asdf'
93
+ else 'mise, rbenv, chruby or asdf'
94
+ end
95
+ end
96
+
97
+ # "3.4" pins any 3.4.x; "latest"/"system" pin nothing we can check.
98
+ def self.version_matches?(pinned, running)
99
+ return true unless pinned.match?(/\A\d+(\.\d+)*\z/)
100
+
101
+ running.split('.').first(pinned.split('.').length) == pinned.split('.')
102
+ end
103
+
104
+ private
105
+
106
+ def repo_root(dir)
107
+ found = ProjectConfig.find_file(dir)
108
+ found ? File.dirname(found) : dir
109
+ end
110
+
111
+ def ruby_report(dir)
112
+ running = RUBY_VERSION
113
+ pin = self.class.ruby_pin(dir)
114
+ mismatch = pin && !self.class.version_matches?(pin[:version], running)
115
+ fp_on_path = which('fp')
116
+
117
+ {
118
+ version: running,
119
+ ruby: RbConfig.ruby,
120
+ fp_version: Fp::VERSION,
121
+ gem_path: gem_path,
122
+ gem_bindir: Gem.bindir,
123
+ fp_on_path: fp_on_path,
124
+ fp_on_path_is_shim: fp_on_path&.include?('/shims/') || false,
125
+ pinned: pin && pin[:version],
126
+ pinned_by: pin && pin[:file],
127
+ mismatch: !!mismatch,
128
+ fixes: mismatch ? shim_fixes(pin, running) : []
129
+ }
130
+ end
131
+
132
+ def shim_fixes(pin, running)
133
+ version = pin[:version]
134
+ fixes = []
135
+ fixes << if pin[:manager] == 'mise' || which('mise')
136
+ "mise exec ruby@#{version} -- gem install featureparity"
137
+ elsif which('rbenv')
138
+ 'gem install featureparity && rbenv rehash # run inside this directory'
139
+ elsif which('asdf')
140
+ 'gem install featureparity && asdf reshim ruby # run inside this directory'
141
+ else
142
+ "gem install featureparity # run inside this directory, under Ruby #{version}"
143
+ end
144
+ fixes << "export PATH=\"#{Gem.bindir}:$PATH\" # or keep using fp from Ruby #{running}"
145
+ fixes
146
+ end
147
+
148
+ def gem_path
149
+ spec = Gem.loaded_specs['featureparity']
150
+ spec ? spec.full_gem_path : File.expand_path('../../..', __dir__)
151
+ end
152
+
153
+ def which(name)
154
+ ENV['PATH'].to_s.split(File::PATH_SEPARATOR).each do |entry|
155
+ candidate = File.join(entry, name)
156
+ return candidate if File.file?(candidate) && File.executable?(candidate)
157
+ end
158
+ nil
159
+ end
160
+
161
+ def config_report(dir)
162
+ r = config.resolved
163
+ project = ProjectConfig.discover(dir)
164
+ {
165
+ environment: r[:environment],
166
+ api_url: r[:api_url],
167
+ profile: r[:profile],
168
+ production: r[:environment] == 'production',
169
+ api_key_present: r[:api_key_present],
170
+ api_key_source: r[:api_key_source],
171
+ project_config: project.path,
172
+ project: project.project
173
+ }
174
+ end
175
+
176
+ # Which fp wrote this repo's AGENTS.md section, so a stale one is visible.
177
+ def agents_section_report(root)
178
+ Init::AGENT_DOC_CANDIDATES.filter_map do |name|
179
+ file = File.join(root, name)
180
+ next unless File.file?(file)
181
+
182
+ content = File.read(file)
183
+ next unless content.include?(Init::AGENTS_MARKER)
184
+
185
+ written = content[Init::AGENTS_VERSION_RE, 1]
186
+ { path: name, written_by: written, current: written == Fp::VERSION }
187
+ end
188
+ end
189
+
190
+ def print_report(report)
191
+ ruby = report[:ruby]
192
+ cfg = report[:config]
193
+ repo = report[:repo]
194
+
195
+ puts "fp #{ruby[:fp_version]} on Ruby #{ruby[:version]} (#{ruby[:ruby]})"
196
+ puts " Gem: #{ruby[:gem_path]}"
197
+ puts " fp on PATH: #{ruby[:fp_on_path] || '(not found)'}#{ruby[:fp_on_path_is_shim] ? ' [version-manager shim]' : ''}"
198
+ if ruby[:pinned]
199
+ state = ruby[:mismatch] ? '⚠ differs from the Ruby running fp' : 'matches'
200
+ puts " Ruby pin: #{ruby[:pinned]} (#{ruby[:pinned_by]}) — #{state}"
201
+ end
202
+ if ruby[:mismatch]
203
+ puts
204
+ puts " This directory pins Ruby #{ruby[:pinned]}, but fp is installed under #{ruby[:version]}."
205
+ puts ' A version-manager shim runs `fp` with the pinned Ruby, where the gem is missing, and'
206
+ puts ' fails before fp starts (`mise ERROR No version is set for shim: fp`). Fix one of:'
207
+ ruby[:fixes].each { |fix| puts " #{fix}" }
208
+ end
209
+
210
+ puts
211
+ puts 'Config'
212
+ env = cfg[:environment] || '(unknown)'
213
+ puts " Environment: #{env} (#{cfg[:api_url]})#{cfg[:profile] ? " [profile #{cfg[:profile]}]" : ''}"
214
+ puts " ⚠ Not production: writes go to '#{env}'. Run `fp whoami` to see why." unless cfg[:production]
215
+ puts " API key: #{cfg[:api_key_present] ? "present [from #{cfg[:api_key_source]}]" : 'not set (export FP_API_KEY=fp_... or fp setup)'}"
216
+ puts " Project file: #{cfg[:project_config] ? "#{cfg[:project_config]} (project: #{cfg[:project] || '?'})" : 'none — run `fp init --detect`, then `fp init --project <slug>`'}"
217
+ report[:agents_section].each do |section|
218
+ stamp = section[:written_by] ? "fp #{section[:written_by]}" : 'an fp that predates version stamps'
219
+ note = section[:current] ? '' : ' — `fp init --force` refreshes it'
220
+ puts " #{section[:path]}: FeatureParity section written by #{stamp}#{note}"
221
+ end
222
+
223
+ puts
224
+ RepoDetector.print_summary(repo)
225
+ end
226
+ end
227
+ end
228
+ end
@@ -39,6 +39,9 @@ module Fp
39
39
 
40
40
  COMMANDS
41
41
  init --project <slug> Onboard this repo: config, CI gate, agent docs
42
+ init --detect Suggest a slug, surfaces and test commands (writes nothing)
43
+ doctor Check Ruby/version-manager pins, config, and what the repo needs
44
+ tutorial Write Hexhaven, a sample repo to practise on
42
45
  projects List projects you have access to
43
46
  projects show <slug> View a project and its surfaces
44
47
  projects create <slug> --name ... Create a project (owned by your key's owner)
@@ -57,11 +60,17 @@ module Fp
57
60
  list --project <slug> List active requirements
58
61
  show <slug> --project <slug> Show requirement details
59
62
  propose --project <slug> ... Propose a new requirement (as draft)
63
+ propose --file reqs.yml Propose a batch of drafts, validated all-or-nothing
64
+ propose ... --dry-run Validate and lint the wording; write nothing
60
65
  propose-change <slug> ... Propose a change to an existing requirement
61
66
  propose-change <slug> --removal Propose deleting a requirement
62
67
  propose-change <slug> --no-parent Propose un-nesting a child to top level
63
68
  propose-change <slug> --withdraw Withdraw your own open proposal (to amend it)
64
69
  comment <slug> --body "..." Comment on a requirement's open change proposal
70
+ question <slug> "<question>" Attach an open question for a human to a draft
71
+ questions <slug> [--open] List a requirement's questions and their answers
72
+ comments <slug> Read the team's comments on a requirement
73
+ comments <slug> --body "..." [--tag who,...] Comment on a requirement, tagging people or agents
65
74
  history <slug> --project <slug> Show a requirement's change history + diffs
66
75
  analyze [<slug>] --project <slug> Triage requirement quality (read-only; never proposes)
67
76
  changes [--source-url URL] [--github-pr] Show or publish work-item-linked activity
@@ -71,12 +80,16 @@ module Fp
71
80
  attach rm <slug> <id> Delete an attachment
72
81
  conversation <id> Read a Pari conversation for agent review
73
82
  report <slug> --project <slug>... Report evidence for a requirement
74
- report --junit <file> ... Upload evidence from a JUnit XML report (no CI)
83
+ mark <slug> <file>:<line> Insert an fp:<slug> marker above a test (file's comment syntax)
84
+ report --junit <file> ... Upload evidence from a JUnit XML or RSpec JSON report (no CI)
85
+ run [report flags] -- <cmd...> Run rspec/pytest/vitest/playwright with a formatter added, then report
75
86
  ci-report --junit <file> ... Ingest CI test results (passing/failing) — CI only
76
87
  ci-report --junit <file> --pr URL Ingest a PR's CI results as pr_passing — CI only
77
88
  changed-tests List unresolved bound-test revision findings
78
89
  changed-tests assess <id> ... Explain whether an exact revision still proves its requirement
90
+ spot-check [<slug>...] Check requirements' tests prove them (random, --count, --all, --test)
79
91
  check Enforce parity; exits non-zero on violations
92
+ coverage [--all] Show which drafts have evidence on which surfaces (never gated)
80
93
  matrix --project <slug> Show the parity matrix
81
94
  export --project <slug> Export all requirements as a Markdown document
82
95
  profile add|list Manage named profiles
@@ -159,6 +172,30 @@ module Fp
159
172
  A stale or later candidate needs a new assessment. A PR candidate becomes accepted only
160
173
  when a default-branch CI report later observes the same fingerprint.
161
174
 
175
+ SPOT CHECKS
176
+ A green receipt says a marked test ran and passed, not that it checks what the
177
+ requirement promises. `fp spot-check` picks an active requirement with evidence at
178
+ random, prints its acceptance beside the source of every locally marked test, and
179
+ gives the agent running it a brief: match each clause to an assertion, then break
180
+ the product code and confirm a test fails. Run it on a schedule or by hand.
181
+
182
+ fp spot-check # a random pick; prints the seed to repeat it
183
+ fp spot-check --count 5 --surface cli --json
184
+ fp spot-check print_container_qr print_label # chosen requirements
185
+ fp spot-check --all # every active requirement with evidence
186
+ fp spot-check --test test/qr_test.rb:42 # the test there, and its requirement
187
+ fp spot-check --count 3 --prompt | claude -p # hand it straight to an agent
188
+ fp spot-check --jev # add Jev's first read (paid plans)
189
+
190
+ `--prompt` prints the spot check as instructions for an agent, with what to report
191
+ back. `--jev` asks Jev, before the agent starts, whether each requirement's tests
192
+ appear to cover its acceptance, and which gap is likeliest. It's a hint for aiming
193
+ the probe, not a verdict. Jev is for paid plans: without one the spot check runs
194
+ without it.
195
+
196
+ It records nothing and never edits a test or a requirement. A test that stays
197
+ green when the promise is broken goes to a human.
198
+
162
199
  AUTHENTICATION
163
200
  Set FP_API_KEY environment variable, or use a profile:
164
201
 
@@ -422,6 +459,9 @@ module Fp
422
459
  # fp parses the report, finds the fp:<slug> markers in the test files,
423
460
  # and reports evidence for every requirement the suite covered.
424
461
  # rspec --format RspecJunitFormatter --out junit.xml # (or your runner's equivalent)
462
+ # RSpec's own `--format json --out rspec.json` works as --junit too, with no extra gem.
463
+ # Or let fp add the formatter and report in one step (rspec, pytest, vitest, playwright):
464
+ # fp run --surface api --repo stowzilla/marketplace -- bundle exec rspec
425
465
  fp report --junit junit.xml --project stowzilla \\
426
466
  --surface api \\
427
467
  --repo stowzilla/marketplace \\
@@ -31,7 +31,11 @@ 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
+ 'question_asked' => 'question asked',
36
+ 'question_resolved' => 'question resolved',
37
+ 'change_commented' => 'commented on the proposal',
38
+ 'commented' => 'commented'
35
39
  }.freeze
36
40
 
37
41
  # A member's own stance on a requirement that already has a status (Fizzy #1478),
@@ -110,6 +114,9 @@ module Fp
110
114
  puts "* #{label} — #{actor} (#{kind}) at #{format_time(change['recorded_at'])}"
111
115
  puts " #{change['reason']}" if present?(change['reason'])
112
116
  puts " note: #{change['note']}" if change['type'] == 'decision_recorded' && present?(change['note'])
117
+ puts " question: #{change['text']}" if present?(change['text'])
118
+ puts " #{change['resolution'] || 'answered'}: #{change['answer']}" if present?(change['answer'])
119
+ print_comment(change) if %w[commented change_commented].include?(change['type'])
113
120
  print_source(change['source'])
114
121
  print_diff(change['changes'])
115
122
  print_evidence_diff(change['evidence_diff'])
@@ -117,6 +124,14 @@ module Fp
117
124
  puts
118
125
  end
119
126
 
127
+ # The team's comments (Fizzy #1494) and comments on a proposal, so an agent reading
128
+ # the history sees the discussion, not just that one happened.
129
+ def print_comment(change)
130
+ change['body'].to_s.each_line { |line| puts " > #{line.chomp}" } if present?(change['body'])
131
+ mentions = Array(change['mentions'])
132
+ puts " tagged: #{mentions.join(', ')}" unless mentions.empty?
133
+ end
134
+
120
135
  def print_source(source)
121
136
  return if source.nil? || source.empty?
122
137