featureparity 0.0.21 → 0.0.22

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.
@@ -19,6 +19,16 @@ module Fp
19
19
 
20
20
  VALID_SOURCE_TYPES = %w[fizzy discord github_issue jira notion manual ai_chat].freeze
21
21
 
22
+ help_summary "Comment on a requirement's open change proposal"
23
+ help_usage 'fp comment <slug> --body "..." --project <slug>'
24
+ help_flag :project, 'Project slug (falls back to .featureparity.yml)'
25
+ help_flag :body, 'The comment text', required: true
26
+ help_flag :change, 'Target a specific change id (defaults to the open proposal)'
27
+ help_flag :source_type, "Origin type: #{VALID_SOURCE_TYPES.join(', ')}"
28
+ help_flag :source_url, 'Origin URL'
29
+ help_flag :source_context, 'Free-form origin note'
30
+ help_example 'fp comment print_qr --body "This conflicts with the freezer label requirement" --project stowzilla'
31
+
22
32
  def run(args)
23
33
  opts, positional = parse_flags(args, KNOWN_FLAGS)
24
34
  require_project!(opts)
@@ -5,6 +5,15 @@ module Fp
5
5
  # fp config get|set|unset|list
6
6
  # Manage global CLI settings (stored in ~/.config/fp/config.yml)
7
7
  class ConfigCmd < Base
8
+ help_summary 'Manage global CLI settings (stored in ~/.config/fp/config.yml)'
9
+ help_usage 'fp config set <key> <value>',
10
+ 'fp config get <key>',
11
+ 'fp config unset <key>',
12
+ 'fp config list'
13
+ help_example 'fp config set api_url https://api.featureparity.dev'
14
+ help_example 'fp config list'
15
+ help_note "Allowed settings: api_url. 'fp config whoami' is an alias for 'fp whoami'."
16
+
8
17
  ALLOWED_SETTINGS = %w[api_url].freeze
9
18
 
10
19
  def run(args)
@@ -17,6 +17,11 @@ module Fp
17
17
  # 4. Agent runs `fp conversation <id>` to read the full context
18
18
  # 5. Agent implements based on Pari's suggestions
19
19
  class Conversation < Base
20
+ help_summary 'Read a Pari conversation so an agent can review AI suggestions'
21
+ help_usage 'fp conversation <conversation-id>'
22
+ help_example 'fp conversation 03h0abc123xyz'
23
+ help_note 'No --project needed; works for project and account-level conversations.'
24
+
20
25
  def run(args)
21
26
  _opts, positional = parse_flags(args)
22
27
  conversation_id = positional.first
@@ -11,6 +11,16 @@ module Fp
11
11
  # attached to a release as a point-in-time record of what the product is
12
12
  # supposed to do.
13
13
  class Export < Base
14
+ help_summary 'Export all requirements as a single Markdown document'
15
+ help_usage 'fp export --project <slug> [--status S] [--category C] [--surface X] [--output FILE]'
16
+ help_flag :project, 'Project slug (falls back to .featureparity.yml)'
17
+ help_flag :status, 'Only requirements with this status'
18
+ help_flag :category, 'Only requirements in this category'
19
+ help_flag :surface, 'Only requirements that name this surface'
20
+ help_flag :output, 'Write to this file instead of stdout'
21
+ help_example 'fp export --project stowzilla > requirements.md'
22
+ help_example 'fp export --project stowzilla --status active --surface customer_android --output reqs.md'
23
+
14
24
  KNOWN_FLAGS = {
15
25
  project: :string,
16
26
  status: :string,
@@ -2,37 +2,66 @@
2
2
 
3
3
  module Fp
4
4
  module Commands
5
- # fp help
5
+ # fp help [<command>]
6
+ #
7
+ # With no argument, prints the top-level usage. With a command name, prints that
8
+ # command's own help — the same page `fp <command> --help` shows (Fizzy #1489).
6
9
  class Help < Base
7
- def run(_args)
10
+ def run(args)
11
+ topic = args.first
12
+ if topic && Fp::CLI::COMMANDS.key?(topic)
13
+ puts Fp::CLI::COMMANDS[topic].help_text(topic)
14
+ return
15
+ end
16
+
17
+ if topic && !topic.start_with?('-')
18
+ output.error("Unknown command: #{topic}") if output
19
+ warn "Unknown command: #{topic}" unless output
20
+ puts "\nRun 'fp help' for the list of commands."
21
+ return
22
+ end
23
+
24
+ print_overview
25
+ end
26
+
27
+ private
28
+
29
+ def print_overview
8
30
  puts <<~HELP
9
31
  fp - FeatureParity CLI for agents
10
32
 
11
33
  USAGE
12
34
  fp <command> [flags]
35
+ fp <command> --help Show a command's own usage and flags
36
+ fp <command> --help --json ...as a machine-readable flag schema
37
+ fp help <command> Same, spelled the other way
38
+ fp --version Show the installed version (alias: -v)
13
39
 
14
40
  COMMANDS
15
41
  init --project <slug> Onboard this repo: config, CI gate, agent docs
16
42
  projects List projects you have access to
17
43
  projects show <slug> View a project and its surfaces
18
44
  projects create <slug> --name ... Create a project (owned by your key's owner)
19
- projects edit <slug> --name ... Update a project's name or description
45
+ projects edit <slug> --name ... Update a project's name, description, or showroom welcome
20
46
  projects delete <slug> [--yes] Delete a project (asks you to type its slug)
21
47
  surfaces --project <slug> List surfaces for a project
22
48
  surfaces show <key> --project ... View a single surface's details
23
49
  surfaces add <key> --project ... Create a surface
24
- surfaces edit <key> --project ... Change a surface (name, kind, repo, key...)
50
+ surfaces edit <key> --project ... Change a surface (name, kind, repo, key, showroom card...)
25
51
  surfaces delete <key> --project . Delete a surface (asks first; --yes to skip)
52
+ showroom --project <slug> Preview the client showroom as a client sees it
26
53
  list --project <slug> List active requirements
27
54
  show <slug> --project <slug> Show requirement details
28
55
  propose --project <slug> ... Propose a new requirement (as draft)
29
56
  propose-change <slug> ... Propose a change to an existing requirement
30
57
  propose-change <slug> --removal Propose deleting a requirement
58
+ propose-change <slug> --no-parent Propose un-nesting a child to top level
59
+ propose-change <slug> --withdraw Withdraw your own open proposal (to amend it)
31
60
  comment <slug> --body "..." Comment on a requirement's open change proposal
32
61
  history <slug> --project <slug> Show a requirement's change history + diffs
33
62
  analyze [<slug>] --project <slug> Triage requirement quality (read-only; never proposes)
34
63
  changes [--source-url URL] [--github-pr] Show or publish work-item-linked activity
35
- attach <slug> <file> Upload a file attachment to a requirement
64
+ attach <slug> <file> [<file>...] Upload one or more file attachments to a requirement
36
65
  attachments <slug> List attachments for a requirement
37
66
  attachments <slug> <id> Get download URL for an attachment
38
67
  attach rm <slug> <id> Delete an attachment
@@ -51,7 +80,7 @@ module Fp
51
80
  whoami Show the resolved config (profile, env, API URL, key)
52
81
  repos set|get|list|unset Map surfaces to local repo paths
53
82
  setup Interactive first-time setup
54
- version Show version
83
+ version Show version (also: fp --version, fp -v)
55
84
  help Show this help
56
85
 
57
86
  GLOBAL FLAGS
@@ -66,6 +95,24 @@ module Fp
66
95
  so a checkout declares its own project once instead of every CI job and agent
67
96
  repeating it. `fp init` writes that file.
68
97
 
98
+ CLIENT SHOWROOM
99
+ The showroom is the one page a client sees: a welcome note, then a card per
100
+ surface with a link, a button label, and sign-in instructions. The team's
101
+ agents keep that content, because the agent that deployed a surface and made
102
+ its demo accounts is the one that knows the URL and the credentials:
103
+
104
+ fp projects edit <slug> --showroom-welcome "Hi Sam, here's what we built." \\
105
+ # or --showroom-welcome-file welcome.txt
106
+ fp surfaces edit web --showroom-url https://dev.example.com \\
107
+ --showroom-label "Open the web app" \\
108
+ --showroom-instructions-file docs/showroom/web.txt
109
+ fp showroom # what the client will see, and which surfaces are hidden
110
+
111
+ A surface is shown once it has a link or instructions; pass an empty value
112
+ to clear a field (`--showroom-url ""`) and a surface with neither drops out.
113
+ Leaving feedback and turning the share link on or off are for humans, in the
114
+ web app under Project Settings → Showroom.
115
+
69
116
  ENFORCEMENT
70
117
  `fp check` fails a build when the project violates its enforcement level. Make it
71
118
  a required status check on your default branch to block merges.
@@ -287,6 +334,15 @@ module Fp
287
334
  --removal \\
288
335
  --reason "Superseded by print_container_label"
289
336
 
337
+ # Un-nest a child back to the top level (clears its parent)
338
+ fp propose-change print_container_qr_ios --project stowzilla --no-parent \\
339
+ --reason "Promoted to a top-level promise"
340
+
341
+ # Amend a proposal you already opened: the single-open-proposal rule won't let
342
+ # you open a second, so withdraw yours (restoring the requirement) and propose
343
+ # again. Only the author can withdraw; someone else's proposal goes to review.
344
+ fp propose-change print_container_qr --project stowzilla --withdraw
345
+
290
346
  # See what changed, why, and who asked for it
291
347
  fp history print_container_qr --project stowzilla
292
348
  fp history print_container_qr --project stowzilla --pending
@@ -357,6 +413,18 @@ module Fp
357
413
  --work-item https://app.fizzy.do/123/cards/456
358
414
  # --base-dir sets where relative test file paths in the report resolve from
359
415
  # Skipped tests are reported as 'stub'; everything else as 'present'.
416
+ # --test-type sets the kind of test (unit, e2e, ...) for markers that pin
417
+ # none. Give a different suite (e.g. a Playwright e2e run) its own type, or
418
+ # evidence for the same requirement+surface collides on the 'unspecified'
419
+ # bucket and the last report silently overwrites the earlier ones.
420
+
421
+ # Playwright's JUnit reporter is understood natively: titles joined with
422
+ # › (or :: for pytest-style ids) bind by name, a classname relative to the
423
+ # runner's testDir is searched for under base-dir, and --attach-media uploads
424
+ # the videos/screenshots the run recorded ([[ATTACHMENT|…]]) as visual
425
+ # evidence, each nested under the test it proves.
426
+ fp report --junit junit.xml --project stowzilla \\
427
+ --surface web --repo stowzilla/app --base-dir frontend --attach-media
360
428
 
361
429
  # CI ONLY: ingest real pass/fail results. Run in your CI pipeline after
362
430
  # the suite (on green AND red builds). Unlike `fp report`, this writes
@@ -9,6 +9,14 @@ module Fp
9
9
  # proposal, if there is one, is called out at the end because that's the thing
10
10
  # someone still has to decide on.
11
11
  class History < Base
12
+ help_summary "A requirement's change history: what changed, who changed it, and why"
13
+ help_usage 'fp history <slug> --project <slug> [--pending] [--limit N]'
14
+ help_flag :project, 'Project slug (falls back to .featureparity.yml)'
15
+ help_flag :pending, 'Show only the open proposal, if any'
16
+ help_flag :limit, 'Cap the number of events shown'
17
+ help_example 'fp history print_container_qr --project stowzilla'
18
+ help_example 'fp history print_container_qr --project stowzilla --pending'
19
+
12
20
  KNOWN_FLAGS = { project: :string, pending: :boolean, limit: :string }.freeze
13
21
 
14
22
  # Human labels for event types. The wire values are stable identifiers; these are
@@ -23,6 +23,19 @@ module Fp
23
23
  # unless --force, and the AGENTS.md section is appended once and detected by a
24
24
  # marker comment on re-run.
25
25
  class Init < Base
26
+ help_summary 'Onboard a repo: write .featureparity.yml, a CI gate workflow, and agent docs'
27
+ help_usage 'fp init --project <slug> [--surface X] [--level L] [--dir PATH] [--force]'
28
+ help_flag :project, 'Project slug to record in .featureparity.yml', required: true
29
+ help_flag :surface, 'Default surface for this (single-surface) repo'
30
+ help_flag :level, 'Enforcement level to record (usually omit and let the project decide)'
31
+ help_flag :dir, 'Target directory (defaults to the working directory)'
32
+ help_flag :force, 'Refresh files fp previously wrote'
33
+ help_flag :no_workflow, 'Skip writing the CI workflow'
34
+ help_flag :no_agents, 'Skip appending the AGENTS.md section'
35
+ help_example 'fp init --project stowzilla --surface api'
36
+ help_example 'fp init --project stowzilla --no-workflow'
37
+ help_note 'Makes no API calls, so a repo can be onboarded before anyone has a key.'
38
+
26
39
  KNOWN_FLAGS = {
27
40
  project: :string,
28
41
  surface: :string,
@@ -174,6 +187,9 @@ module Fp
174
187
  puts ' 2. Add the FP_API_KEY secret to the repo so CI can report evidence and run the gate.'
175
188
  puts " 3. Try the gate locally: fp check#{opts[:surface] ? " --surface #{opts[:surface]}" : ''}"
176
189
  puts
190
+ puts 'Until the FP_API_KEY secret is added, the gate skips with an explanation'
191
+ puts 'instead of failing — so the first push that onboards the repo stays green.'
192
+ puts
177
193
  puts 'The gate starts at the `regression` level, which only fails on tests that have'
178
194
  puts 'gone red — it will not block you on requirements that were never covered. Raise'
179
195
  puts 'the level to `covered` or `strict` once the gaps are paid down.'
@@ -218,6 +234,13 @@ module Fp
218
234
  permissions:
219
235
  contents: read
220
236
  pull-requests: write
237
+ # Whether the FP_API_KEY secret exists on this repo. Every step that talks
238
+ # to FeatureParity is guarded on this, so the very first push — before
239
+ # anyone has added the secret — explains what to do instead of dying with a
240
+ # bare "No API key found" from fp. Fork pull requests also can't read the
241
+ # secret, and are skipped the same way.
242
+ env:
243
+ HAVE_FP_KEY: ${{ secrets.FP_API_KEY != '' }}
221
244
  steps:
222
245
  - uses: actions/checkout@v4
223
246
 
@@ -228,8 +251,30 @@ module Fp
228
251
  - name: Install fp
229
252
  run: gem install featureparity
230
253
 
254
+ # Without the secret there is nothing the gate can do yet, so say so
255
+ # clearly (in the job summary and the log) and let the build pass. This is
256
+ # the expected state right after `fp init`: the repo is onboarded, the
257
+ # maintainer just hasn't added the key. A green, explanatory run beats a
258
+ # red one nobody can action.
259
+ - name: FeatureParity not configured yet
260
+ if: env.HAVE_FP_KEY != 'true'
261
+ run: |
262
+ {
263
+ echo '## FeatureParity gate skipped'
264
+ echo ''
265
+ echo 'The `FP_API_KEY` secret is not set on this repository, so the parity'
266
+ echo 'gate cannot run yet. This is expected on the first push after `fp init`.'
267
+ echo ''
268
+ echo 'To enable the gate, add a repository secret named `FP_API_KEY`'
269
+ echo '(Settings → Secrets and variables → Actions → New repository secret).'
270
+ echo 'Mint a key at https://featureparity.dev (Account → Agents & API keys).'
271
+ echo ''
272
+ echo 'Fork pull requests cannot read the secret and will always show this.'
273
+ } >> "$GITHUB_STEP_SUMMARY"
274
+ echo 'FP_API_KEY is not set — skipping the parity gate. See the job summary.'
275
+
231
276
  - name: Update PR requirement changes
232
- if: github.event_name == 'pull_request'
277
+ if: github.event_name == 'pull_request' && env.HAVE_FP_KEY == 'true'
233
278
  env:
234
279
  FP_API_KEY: ${{ secrets.FP_API_KEY }}
235
280
  GITHUB_TOKEN: ${{ github.token }}
@@ -247,6 +292,9 @@ module Fp
247
292
  # Add `--warn-only` while adopting if you need the annotations without
248
293
  # blocking merges yet.
249
294
  #
295
+ # Guarded on HAVE_FP_KEY so a repo that hasn't added the secret yet gets
296
+ # the explanatory step above instead of a bare "No API key found" failure.
297
+ #
250
298
  # NOTE: as written this gates on what has been *recorded*, which on a pull
251
299
  # request is the default branch's state. To judge a branch on its own
252
300
  # results, emit JUnit in this job and pass it — nothing is saved:
@@ -258,6 +306,7 @@ module Fp
258
306
  # suite stays green while the requirement loses its only proof. Use one
259
307
  # call per surface.
260
308
  - name: Check parity
309
+ if: env.HAVE_FP_KEY == 'true'
261
310
  env:
262
311
  FP_API_KEY: ${{ secrets.FP_API_KEY }}
263
312
  run: fp check
@@ -5,6 +5,18 @@ module Fp
5
5
  # fp list --project <slug> [--surface X] [--category Y] [--gaps] [--status draft]
6
6
  # List requirements, optionally filtered
7
7
  class List < Base
8
+ help_summary 'List requirements, optionally filtered'
9
+ help_usage 'fp list --project <slug> [--surface X] [--category Y] [--tag Z] [--status S] [--gaps]'
10
+ help_flag :project, 'Project slug (falls back to .featureparity.yml)'
11
+ help_flag :surface, 'Only requirements that name this surface'
12
+ help_flag :category, 'Filter by category'
13
+ help_flag :tag, 'Filter by a free-form tag'
14
+ help_flag :status, 'Filter by status: active (default), draft, archived'
15
+ help_flag :gaps, 'Show only requirements with an uncovered required surface (reads evidence)'
16
+ help_example 'fp list --project stowzilla'
17
+ help_example 'fp list --project stowzilla --status draft', 'Drafts are hidden by default'
18
+ help_example 'fp list --project stowzilla --gaps --surface customer_android'
19
+
8
20
  KNOWN_FLAGS = {
9
21
  project: :string,
10
22
  surface: :string,
@@ -13,6 +13,15 @@ module Fp
13
13
  # parity report, which is the same computation the CI gate blocks on, so what you
14
14
  # see here is what CI will decide.
15
15
  class Matrix < Base
16
+ help_summary 'Show the parity matrix (requirements × surfaces with evidence state)'
17
+ help_usage 'fp matrix --project <slug> [--surface api] [--level covered] [--csv]'
18
+ help_flag :project, 'Project slug (falls back to .featureparity.yml)'
19
+ help_flag :surface, 'Limit to one surface'
20
+ help_flag :level, 'Evaluate against this enforcement level (off/regression/covered/strict)'
21
+ help_flag :csv, 'Emit CSV instead of the ASCII table'
22
+ help_example 'fp matrix --project stowzilla'
23
+ help_example 'fp matrix --project stowzilla --csv > matrix.csv'
24
+
16
25
  KNOWN_FLAGS = {
17
26
  project: :string,
18
27
  surface: :string,
@@ -5,6 +5,15 @@ module Fp
5
5
  # fp profile add|list
6
6
  # Manage named profiles for API authentication
7
7
  class Profile < Base
8
+ help_summary 'Manage named authentication profiles'
9
+ help_usage 'fp profile add <name> --api-key fp_... [--api-url URL] [--environment ENV]',
10
+ 'fp profile list'
11
+ help_flag :api_key, 'API key for the profile (starts with fp_)'
12
+ help_flag :api_url, 'Override API URL for the profile'
13
+ help_flag :environment, 'Named environment for the profile'
14
+ help_example 'fp profile add pari --api-key fp_...'
15
+ help_example 'fp --profile pari projects'
16
+
8
17
  KNOWN_FLAGS = {
9
18
  api_key: :string,
10
19
  api_url: :string,
@@ -17,7 +17,27 @@ module Fp
17
17
  # or takes --yes to skip the prompt, and a non-interactive session without --yes is
18
18
  # refused rather than guessed at. The server adds its own check — only an owner (or an
19
19
  # owner's key) may delete.
20
+ #
21
+ # `edit --showroom-welcome` keeps the note at the top of the client showroom (Fizzy
22
+ # #1479); the per-surface cards are `fp surfaces edit --showroom-*`, and `fp showroom`
23
+ # previews the result.
20
24
  class Projects < Base
25
+ help_summary 'List, view, create, edit, and delete projects'
26
+ help_usage 'fp projects',
27
+ 'fp projects show <slug>',
28
+ 'fp projects create <slug> --name "..." [--description ...] [--surfaces a,b,c]',
29
+ 'fp projects edit <slug> [--name ...] [--description ...]',
30
+ 'fp projects delete <slug> [--yes]'
31
+ help_flag :name, 'Project name (create/edit)'
32
+ help_flag :description, 'Project description (create/edit)'
33
+ help_flag :showroom_welcome, 'Welcome note at the top of the client showroom (edit; empty clears)'
34
+ help_flag :showroom_welcome_file, 'Read --showroom-welcome from a file (multi-line)'
35
+ help_flag :surfaces, 'Comma-separated surface keys to create with the project'
36
+ help_flag :yes, 'Skip the typed-slug confirmation on delete'
37
+ help_example 'fp projects create calib-rate --name "Calib Rate" --surfaces api,web'
38
+ help_example 'fp projects delete calib-rate --yes'
39
+ help_note 'Projects land on the key owner\'s account; the same key reaches them immediately. Delete is owner-only.'
40
+
21
41
  CREATE_FLAGS = {
22
42
  name: :string,
23
43
  description: :string,
@@ -26,7 +46,9 @@ module Fp
26
46
 
27
47
  EDIT_FLAGS = {
28
48
  name: :string,
29
- description: :string
49
+ description: :string,
50
+ showroom_welcome: :string,
51
+ showroom_welcome_file: :string
30
52
  }.freeze
31
53
 
32
54
  DELETE_FLAGS = {
@@ -135,14 +157,17 @@ module Fp
135
157
 
136
158
  def edit(args)
137
159
  opts, positional = parse_flags(args, EDIT_FLAGS)
138
- slug = require_slug!(positional, 'fp projects edit <slug> [--name "..."] [--description "..."]')
160
+ slug = require_slug!(positional, 'fp projects edit <slug> [--name "..."] [--description "..."] ' \
161
+ '[--showroom-welcome "..." | --showroom-welcome-file PATH]')
139
162
 
140
163
  params = {}
141
164
  params[:name] = opts[:name] if opts[:name]
142
165
  params[:description] = opts[:description] if opts.key?(:description)
166
+ welcome = showroom_welcome_param(opts)
167
+ params[:showroom_welcome] = welcome unless welcome.nil?
143
168
 
144
169
  if params.empty?
145
- output.error('Nothing to change. Pass --name and/or --description.')
170
+ output.error('Nothing to change. Pass --name, --description, --showroom-welcome, or --showroom-welcome-file.')
146
171
  exit 1
147
172
  end
148
173
 
@@ -161,9 +186,40 @@ module Fp
161
186
  puts
162
187
  puts " Name: #{record['name']}"
163
188
  puts " Description: #{blank?(record['description']) ? '(none)' : record['description']}"
189
+ if params.key?(:showroom_welcome)
190
+ puts " Showroom welcome: #{blank?(record['showroom_welcome']) ? '(cleared)' : ''}"
191
+ record['showroom_welcome'].to_s.each_line { |line| puts " #{line.rstrip}" }
192
+ end
164
193
  end
165
194
  end
166
195
 
196
+ # The welcome note from --showroom-welcome or --showroom-welcome-file, nil when
197
+ # neither was passed. An empty value clears the note. A file is the sane way to
198
+ # write several paragraphs; the showroom keeps the line breaks.
199
+ def showroom_welcome_param(opts)
200
+ if opts.key?(:showroom_welcome) && opts.key?(:showroom_welcome_file)
201
+ output.error('Pass --showroom-welcome or --showroom-welcome-file, not both.')
202
+ exit 1
203
+ end
204
+
205
+ if opts.key?(:showroom_welcome_file)
206
+ path = opts[:showroom_welcome_file]
207
+ if path == true || path.to_s.strip.empty?
208
+ output.error('--showroom-welcome-file needs a path')
209
+ exit 1
210
+ end
211
+ unless File.file?(path)
212
+ output.error("--showroom-welcome-file: no such file '#{path}'")
213
+ exit 1
214
+ end
215
+ return File.read(path).strip
216
+ end
217
+
218
+ return nil unless opts.key?(:showroom_welcome)
219
+
220
+ opts[:showroom_welcome] == true ? '' : opts[:showroom_welcome].to_s
221
+ end
222
+
167
223
  def delete(args)
168
224
  opts, positional = parse_flags(args, DELETE_FLAGS)
169
225
  slug = require_slug!(positional, 'fp projects delete <slug> [--yes]')
@@ -276,6 +332,10 @@ module Fp
276
332
  puts
277
333
  puts "Slug: #{project['slug']}"
278
334
  puts "Description: #{blank?(project['description']) ? '(none)' : project['description']}"
335
+ unless blank?(project['showroom_welcome'])
336
+ puts 'Showroom welcome:'
337
+ project['showroom_welcome'].to_s.each_line { |line| puts " #{line.rstrip}" }
338
+ end
279
339
  puts "Enforcement: #{project['enforcement_level']}" if project['enforcement_level']
280
340
  puts "Surfaces: #{surfaces.empty? ? '(none)' : surfaces.join(', ')}"
281
341
 
@@ -5,6 +5,24 @@ module Fp
5
5
  # fp propose --project <slug> --slug X --name "..." --why "..." --required a,b,c --required-types unit,e2e --acceptance "..." --parent <slug> --category <cat>
6
6
  # Create a new requirement as draft
7
7
  class Propose < Base
8
+ help_summary 'Propose a new requirement (created as a draft; a human activates it)'
9
+ help_usage 'fp propose --project <slug> --slug <key> --name "..." [flags]'
10
+ help_flag :project, 'Project slug (falls back to .featureparity.yml)'
11
+ help_flag :slug, 'Initial key for the requirement', required: true
12
+ help_flag :name, 'Human-readable title (one product promise, present tense)', required: true
13
+ help_flag :why, 'One or two sentences: who needs it, the problem, the outcome'
14
+ help_flag :acceptance, 'Observable result + boundaries a reviewer can check'
15
+ help_flag :required, 'Comma-separated surfaces this requirement must be covered on'
16
+ help_flag :required_types, "Comma-separated test kinds each surface needs (project's vocabulary)"
17
+ help_flag :parent, "Parent requirement's slug (nests one level; subset of parent's surfaces)"
18
+ help_flag :category, 'One of: functional, non-functional, ux, performance, security, compliance'
19
+ help_flag :tags, 'Comma-separated free-form labels'
20
+ help_flag :source_type, 'Where it came from: fizzy, discord, github_issue, jira, notion, manual, ai_chat'
21
+ help_flag :source_url, 'URL of the originating card/issue/thread'
22
+ help_flag :source_context, 'Free-form note about the origin'
23
+ help_example 'fp propose --project stowzilla --slug print_qr --name "Print QR on container label" --why "Staff mis-type the ID without a scannable code" --required api,customer_ios --acceptance "QR scans to the container detail page"'
24
+ help_note 'Agents can only create drafts. A human activates it in the web app.'
25
+
8
26
  KNOWN_FLAGS = {
9
27
  project: :string,
10
28
  slug: :string,
@@ -10,11 +10,36 @@ module Fp
10
10
  # asked for, and what prompted the ask. The requirement drops back to draft until a
11
11
  # human reviews the diff — agents never approve their own changes.
12
12
  class ProposeChange < Base
13
+ help_summary 'Propose a change to an existing requirement (or --removal to retire it)'
14
+ help_usage 'fp propose-change <slug> --project <slug> [--name/--why/--acceptance ...] --reason "..."',
15
+ 'fp propose-change <slug> --removal --reason "..."'
16
+ help_flag :project, 'Project slug (falls back to .featureparity.yml)'
17
+ help_flag :name, 'Proposed new title'
18
+ help_flag :why, 'Proposed new Why'
19
+ help_flag :acceptance, 'Proposed new acceptance criteria'
20
+ help_flag :required, 'Proposed new required surfaces (comma-separated; cannot remove a surface)'
21
+ help_flag :required_types, 'Proposed new required test types (comma-separated)'
22
+ help_flag :category, 'Proposed new category: functional, non-functional, ux, performance, security, compliance'
23
+ help_flag :parent, "Set the parent requirement's slug"
24
+ help_flag :no_parent, 'Propose un-nesting the requirement back to the top level (clears --parent)'
25
+ help_flag :slug, 'Proposed new slug (evidence follows; markers still need updating)'
26
+ help_flag :reason, 'What prompted this change (use this, not --source-*, to record the prompt)'
27
+ help_flag :removal, 'Propose deleting the requirement instead of changing it'
28
+ help_flag :withdraw, 'Take back the open proposal you authored, restoring the requirement as it was'
29
+ help_flag :source_type, "Correct the requirement's recorded origin type (not the change's prompt)"
30
+ help_flag :source_url, "Correct the requirement's recorded origin URL"
31
+ help_flag :source_context, "Correct the requirement's recorded origin context"
32
+ help_example 'fp propose-change print_qr --project stowzilla --acceptance "QR scans after 24h at -20C" --reason "Cold-chain rollout"'
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. Use --reason\n" \
35
+ "to say what prompted it; --source-* overwrite the requirement's own origin fields."
36
+
13
37
  KNOWN_FLAGS = {
14
38
  project: :string, name: :string, why: :string, acceptance: :string,
15
39
  required: :string, required_types: :string, category: :string, parent: :string, slug: :string,
40
+ no_parent: :boolean,
16
41
  reason: :string, source_type: :string, source_url: :string, source_context: :string,
17
- removal: :boolean
42
+ removal: :boolean, withdraw: :boolean
18
43
  }.freeze
19
44
 
20
45
  VALID_CATEGORIES = %w[functional non-functional ux performance security compliance].freeze
@@ -39,17 +64,21 @@ module Fp
39
64
  exit 1
40
65
  end
41
66
 
67
+ validate_flag_combinations!(opts)
42
68
  validate_enums!(opts)
43
69
 
44
70
  project_id = resolve_project_id(opts[:project])
45
71
  requirement = find_requirement!(project_id, slug)
46
72
 
73
+ return withdraw!(requirement, slug) if opts[:withdraw]
74
+
47
75
  params = build_params(opts, project_id)
48
76
  removal = opts[:removal] ? true : false
49
77
 
50
78
  if !removal && params.keys.none? { |k| proposed_field?(k) }
51
79
  output.error('Nothing to propose. Pass at least one of --name/--slug/--why/--acceptance/' \
52
- '--required/--required-types/--category/--parent, or --removal to propose deletion.')
80
+ '--required/--required-types/--category/--parent/--no-parent, or --removal to ' \
81
+ 'propose deletion.')
53
82
  exit 1
54
83
  end
55
84
 
@@ -68,6 +97,31 @@ module Fp
68
97
 
69
98
  private
70
99
 
100
+ # Withdraw the requirement's own open proposal (Fizzy #1492). The single-open-
101
+ # proposal rule means an agent that wants to *amend* what it proposed can't just
102
+ # open a second one — it withdraws the first (restoring the requirement to what it
103
+ # was) and proposes again. The server only lets the proposal's author withdraw it.
104
+ def withdraw!(requirement, slug)
105
+ change_id = requirement['pending_change_id']
106
+ if change_id.nil? || change_id.to_s.empty?
107
+ output.error("Requirement '#{slug}' has no open proposal to withdraw.")
108
+ exit 1
109
+ end
110
+
111
+ result = client.withdraw_requirement_change(requirement['id'], change_id)
112
+ unless result[:ok]
113
+ output.error(result[:error], status: result[:status])
114
+ exit 1
115
+ end
116
+
117
+ output.success(result[:data]) do
118
+ puts "Withdrew the open proposal for '#{slug}'."
119
+ puts
120
+ puts 'The requirement is restored to what it was before the proposal.'
121
+ puts 'To amend it, propose the change again with the corrected values.'
122
+ end
123
+ end
124
+
71
125
  # A 409 means the requirement already has an open proposal — the one-at-a-time rule
72
126
  # (two "what it was" snapshots would make a rollback ambiguous). Rather than a dead
73
127
  # end, show what the open proposal is and point at `fp comment` so the agent can
@@ -109,6 +163,20 @@ module Fp
109
163
  warn ' Fields: ' + changes.keys.join(', ')
110
164
  end
111
165
 
166
+ # --parent and --no-parent contradict each other, and --withdraw is a standalone
167
+ # verb that ignores proposed values (Fizzy #1492).
168
+ def validate_flag_combinations!(opts)
169
+ if opts[:parent] && opts[:no_parent]
170
+ output.error('Pass either --parent <slug> or --no-parent, not both.')
171
+ exit 1
172
+ end
173
+
174
+ return unless opts[:withdraw] && opts[:removal]
175
+
176
+ output.error('--withdraw cancels an open proposal; it cannot be combined with --removal.')
177
+ exit 1
178
+ end
179
+
112
180
  def validate_enums!(opts)
113
181
  if opts[:category] && !VALID_CATEGORIES.include?(opts[:category])
114
182
  output.error("Invalid category '#{opts[:category]}'. Must be one of: #{VALID_CATEGORIES.join(', ')}")
@@ -143,6 +211,9 @@ module Fp
143
211
  params[:required_test_types] = opts[:required_types].split(',').map { |t| t.strip.downcase }.reject(&:empty?)
144
212
  end
145
213
  params[:parent_id] = resolve_parent_id(project_id, opts[:parent]) if opts[:parent]
214
+ # --no-parent un-nests a child back to top level: an empty parent_id. The server
215
+ # treats "" as "no parent" and diffs it against the current parent (Fizzy #1492).
216
+ params[:parent_id] = '' if opts[:no_parent]
146
217
 
147
218
  params[:removal] = true if opts[:removal]
148
219
  params[:reason] = opts[:reason] if opts[:reason]