featureparity 0.0.14 → 0.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/fp/junit.rb CHANGED
@@ -32,8 +32,13 @@ module Fp
32
32
  # One requirement's aggregated CI outcome for one surface, ready to hand to the
33
33
  # backend's ingest endpoint. This is what lets CI upload *resolved* results rather
34
34
  # than raw XML the server would have to re-scrape the repo to understand.
35
- CiResult = Struct.new(:slug, :surface, :status, :file, :line, :test_count, :failure_count,
36
- keyword_init: true)
35
+ #
36
+ # `title` names a representative test — the first one that *failed* when the verdict
37
+ # is `failing`, otherwise the first that ran. Without it the evidence row keeps
38
+ # whatever title an earlier agent report left behind, and the web app then blames a
39
+ # test that is perfectly green for the failure.
40
+ CiResult = Struct.new(:slug, :surface, :test_type, :status, :file, :line, :title, :test_count,
41
+ :failure_count, keyword_init: true)
37
42
 
38
43
  # A failing testcase that carries no fp:<slug> marker at all — a "loose end". Not a
39
44
  # cell on the parity grid (nothing binds it to a requirement) and not an orphan
@@ -46,9 +51,13 @@ module Fp
46
51
  # A testcase paired with the marker that annotates it. `precise` records whether we
47
52
  # know *which* test the marker sits above (matched by line or by test name) or only
48
53
  # that the marker is somewhere in the same file — a title is only worth reporting in
49
- # the first case. `repo_file` is the testcase's source path made relative to the git
50
- # repo root (see repo_relative_file), so evidence links resolve regardless of the
51
- # base_dir the runner reported paths against.
54
+ # the first case.
55
+ #
56
+ # `repo_file` is the testcase's source file expressed relative to the *repository
57
+ # root*, which is what gets reported. The path in the report is relative to whatever
58
+ # directory the runner was invoked from (vitest reports `src/pages/Foo.test.jsx` from
59
+ # inside `frontend/`), so reporting it verbatim produces a link to a file that doesn't
60
+ # exist at that path in the repo.
52
61
  Match = Struct.new(:testcase, :marker, :precise, :repo_file, keyword_init: true)
53
62
 
54
63
  # Evidence discovered by binding a testcase to a marker.
@@ -56,18 +65,24 @@ module Fp
56
65
  # testcase's own line when the runner reported one, otherwise the line of
57
66
  # the fp:<slug> marker that annotates it (markers sit directly above the
58
67
  # test, so the marker line is a close stand-in when the runner omits lines).
59
- Binding = Struct.new(:slug, :surface, :file, :line, :title, :state, keyword_init: true)
68
+ Binding = Struct.new(:slug, :surface, :test_type, :file, :line, :title, :state, keyword_init: true)
60
69
 
61
70
  # Matches `fp:<slug>` inside a comment, with an optional `@surface` (or
62
- # `@surface1,surface2`) suffix to target one or more specific surfaces:
71
+ # `@surface1,surface2`) suffix to target one or more specific surfaces, and an
72
+ # optional `:<type>` suffix to pin the kind of test:
63
73
  #
64
- # fp:print_qr # uses the --surface flag as the default
74
+ # fp:print_qr # --surface flag default, unspecified type
65
75
  # fp:print_qr@api # reports evidence for the `api` surface
66
76
  # fp:print_qr@api,web # reports for both `api` and `web`
77
+ # fp:print_qr@api:e2e # the `api` surface, filed as an e2e test
78
+ # fp:print_qr:unit # --surface default, filed as a unit test
79
+ # fp:print_qr@api,web:e2e # both surfaces, each filed as an e2e test
67
80
  #
68
- # Slugs and surfaces are lowercase letters, digits, underscores and hyphens
69
- # (matching the propose/report convention).
70
- MARKER_RE = /fp:([a-z0-9][a-z0-9_-]*)(?:@([a-z0-9_-]+(?:,[a-z0-9_-]+)*))?/.freeze
81
+ # The type pins the kind of test (unit, integration, e2e, manual, other) so a
82
+ # requirement that demands several kinds can tell them apart. It applies to every
83
+ # surface the marker names. Slugs, surfaces and types are lowercase letters, digits,
84
+ # underscores and hyphens (matching the propose/report convention).
85
+ MARKER_RE = /fp:([a-z0-9][a-z0-9_-]*)(?:@([a-z0-9_-]+(?:,[a-z0-9_-]+)*))?(?::([a-z0-9_-]+))?/.freeze
71
86
 
72
87
  # A marker only counts when it is a comment on its own line — the documented
73
88
  # convention ("the fp:<slug> comment sits immediately above its test"). Requiring a
@@ -176,7 +191,7 @@ module Fp
176
191
  # unmatched: [TestCase, ...], # testcases with no discoverable marker
177
192
  # missing_surface: [Binding-ish], # markers with no surface and no default
178
193
  # }
179
- def bind_markers(testcases, base_dir: Dir.pwd, default_surface: nil)
194
+ def bind_markers(testcases, base_dir: Dir.pwd, default_surface: nil, default_test_type: nil)
180
195
  bindings = {}
181
196
  matched = {}
182
197
  missing_surface = []
@@ -189,11 +204,17 @@ module Fp
189
204
  surfaces = marker[:surfaces]
190
205
  surfaces = [default_surface] if surfaces.empty?
191
206
 
207
+ # The marker's own type pin wins; otherwise the --test-type default (nil → the
208
+ # server files it as `unspecified`).
209
+ test_type = marker[:test_type] || default_test_type
210
+
192
211
  surfaces.each do |surface|
193
212
  if surface.nil? || surface.empty?
194
213
  missing_surface << { slug: marker[:slug], file: tc.file }
195
214
  else
196
- record_binding(bindings, marker[:slug], surface, tc, marker, match.repo_file, titles_reliable: match.precise)
215
+ record_binding(bindings, marker[:slug], surface, tc, marker,
216
+ titles_reliable: match.precise, file: match.repo_file,
217
+ test_type: test_type)
197
218
  end
198
219
  end
199
220
  end
@@ -214,7 +235,7 @@ module Fp
214
235
  # it was skipped (a skipped test is not proof of anything), and `passing` otherwise.
215
236
  #
216
237
  # Returns { results: [CiResult, ...], unmatched: [TestCase], missing_surface: [...] }.
217
- def ci_results(testcases, base_dir: Dir.pwd, default_surface: nil)
238
+ def ci_results(testcases, base_dir: Dir.pwd, default_surface: nil, default_test_type: nil)
218
239
  aggregates = {}
219
240
  matched = {}
220
241
  missing_surface = []
@@ -227,31 +248,47 @@ module Fp
227
248
  surfaces = marker[:surfaces]
228
249
  surfaces = [default_surface] if surfaces.empty?
229
250
 
251
+ # The marker's own type pin wins; otherwise the --test-type default (nil → the
252
+ # server files it as `unspecified`). One CI verdict per (slug, surface, type).
253
+ test_type = marker[:test_type] || default_test_type
254
+
230
255
  surfaces.each do |surface|
231
256
  if surface.nil? || surface.empty?
232
257
  missing_surface << { slug: marker[:slug], file: tc.file }
233
258
  next
234
259
  end
235
260
 
236
- key = [marker[:slug], surface]
237
- agg = aggregates[key] ||= { file: match.repo_file || tc.file, line: tc.line || marker[:line], ran: 0, failed: 0, skipped: 0 }
261
+ key = [marker[:slug], surface, test_type]
262
+ agg = aggregates[key] ||= { file: match.repo_file, line: tc.line || marker[:line],
263
+ title: nil, failing_title: nil, ran: 0, failed: 0, skipped: 0 }
238
264
  if tc.stub?
239
265
  agg[:skipped] += 1
240
266
  else
241
267
  agg[:ran] += 1
242
268
  agg[:failed] += 1 if tc.failed?
243
269
  end
270
+
271
+ # Name a test for the evidence row. The first *failing* test is the one a human
272
+ # wants to see when the cell goes red; otherwise the first that ran will do.
273
+ # Only when we know which test the marker annotates — a guess here is worse
274
+ # than no title.
275
+ if match.precise && (title = presence(tc.name))
276
+ agg[:title] ||= title
277
+ agg[:failing_title] ||= title if tc.failed?
278
+ end
244
279
  end
245
280
  end
246
281
 
247
- results = aggregates.map do |(slug, surface), agg|
282
+ results = aggregates.map do |(slug, surface, test_type), agg|
248
283
  status =
249
284
  if agg[:failed].positive? then 'failing'
250
285
  elsif agg[:ran].zero? then 'stub'
251
286
  else 'passing'
252
287
  end
253
288
 
254
- CiResult.new(slug: slug, surface: surface, status: status, file: agg[:file], line: agg[:line],
289
+ CiResult.new(slug: slug, surface: surface, test_type: test_type, status: status,
290
+ file: agg[:file], line: agg[:line],
291
+ title: agg[:failing_title] || agg[:title],
255
292
  test_count: agg[:ran], failure_count: agg[:failed])
256
293
  end
257
294
 
@@ -276,17 +313,14 @@ module Fp
276
313
  # can never disagree about which test a marker annotates.
277
314
  def walk_matches(testcases, base_dir)
278
315
  marker_cache = {}
279
- prefix_cache = {}
316
+ root = repo_root_for(base_dir)
280
317
 
281
318
  # Group testcases by their source file so we can reason about ordering.
282
319
  testcases.group_by(&:file).each do |file, cases|
283
320
  markers = markers_for(cases.first, base_dir, marker_cache)
284
321
  next if markers.empty?
285
322
 
286
- # The path we'll store as evidence: relative to the git root, not to base_dir,
287
- # so a GitHub blob link resolves. base_dir's offset below the root is cached, so
288
- # this is one shell-out per run, not per file.
289
- repo_file = repo_relative_file(file, base_dir, prefix_cache)
323
+ repo_file = repo_relative_path(file, base_dir, root)
290
324
 
291
325
  cases.each do |tc|
292
326
  marker, precise = marker_for(tc, cases, markers)
@@ -297,6 +331,48 @@ module Fp
297
331
  end
298
332
  end
299
333
 
334
+ # The repository root containing `dir`, or nil when `dir` isn't inside one. #
335
+ # Found by walking up for a `.git` entry rather than shelling out to git, so it also
336
+ # works in a worktree (where `.git` is a file) and costs nothing.
337
+ def repo_root_for(dir)
338
+ current = File.expand_path(dir)
339
+
340
+ loop do
341
+ return current if File.exist?(File.join(current, '.git'))
342
+
343
+ parent = File.dirname(current)
344
+ return nil if parent == current
345
+
346
+ current = parent
347
+ end
348
+ end
349
+
350
+ # Express a report's test-file path relative to the repository root.
351
+ #
352
+ # The path in a JUnit report is relative to wherever the runner ran, which is not
353
+ # necessarily the repo root — vitest invoked in `frontend/` reports
354
+ # `src/pages/Matrix.test.jsx`, and `--base-dir frontend` is what makes that resolvable
355
+ # locally. Evidence is rendered as a GitHub link built from the repo plus this path,
356
+ # so reporting it verbatim links to a path that doesn't exist (a 404).
357
+ #
358
+ # Falls back to the path as given when there's no repo to relativise against, or when
359
+ # the file resolves outside it.
360
+ def repo_relative_path(file, base_dir, root = repo_root_for(base_dir))
361
+ return file if file.nil? || file.empty? || root.nil?
362
+
363
+ absolute = File.expand_path(file, base_dir)
364
+ prefix = "#{root}#{File::SEPARATOR}"
365
+ return file unless absolute.start_with?(prefix)
366
+
367
+ absolute.delete_prefix(prefix)
368
+ end
369
+
370
+ # nil for blank, the stripped value otherwise.
371
+ def presence(value)
372
+ stripped = value.to_s.strip
373
+ stripped.empty? ? nil : stripped
374
+ end
375
+
300
376
  # Determine the marker (slug + surfaces) that annotates a testcase.
301
377
  # Returns [marker, precise] — or [nil, false] when nothing can be tied to it.
302
378
  def marker_for(testcase, sibling_cases, markers)
@@ -371,9 +447,12 @@ module Fp
371
447
  candidate
372
448
  end
373
449
 
374
- def record_binding(bindings, slug, surface, testcase, marker, repo_file, titles_reliable: true)
375
- rel_file = repo_file || testcase.file
376
- key = [slug, surface, rel_file]
450
+ def record_binding(bindings, slug, surface, testcase, marker, titles_reliable: true, file: nil,
451
+ test_type: nil)
452
+ rel_file = file || testcase.file
453
+ # test_type is part of the key: a unit test and an e2e test for the same
454
+ # slug/surface/file are distinct evidence rows, so they mustn't collapse into one.
455
+ key = [slug, surface, test_type, rel_file]
377
456
  state = testcase.stub? ? 'stub' : 'present'
378
457
 
379
458
  # The test's line: prefer the runner-reported testcase line; fall back to
@@ -385,8 +464,8 @@ module Fp
385
464
  # Prefer a concrete 'present' state over 'stub' when a slug has both.
386
465
  return if existing && !(existing.state == 'stub' && state == 'present')
387
466
 
388
- bindings[key] = Binding.new(slug: slug, surface: surface, file: rel_file, line: line,
389
- title: (testcase.name if titles_reliable), state: state)
467
+ bindings[key] = Binding.new(slug: slug, surface: surface, test_type: test_type, file: rel_file,
468
+ line: line, title: (testcase.name if titles_reliable), state: state)
390
469
  end
391
470
 
392
471
  # Load and cache the fp:<slug> markers (with line numbers) for a testcase's
@@ -408,73 +487,10 @@ module Fp
408
487
  cache[file] = markers
409
488
  end
410
489
 
411
- # Rewrite a report-relative test-file path so it is relative to the git repo root.
412
- #
413
- # Runners report paths relative to wherever they ran: RSpec/minitest from the repo
414
- # root, but vitest from its own package (frontend/), which is why the web job passes
415
- # `--base-dir frontend`. That base_dir is enough to *find* the markers, but the path
416
- # we then store as `source_file` was the raw report path (`src/pages/Matrix.test.jsx`),
417
- # not the repo-root path (`frontend/src/pages/Matrix.test.jsx`). The web app builds a
418
- # GitHub link as `blob/<sha>/<source_file>`, and a blob path is always relative to the
419
- # git root — so the missing `frontend/` prefix produced a 404 on every web test link.
420
- #
421
- # The fix is purely additive: find how far base_dir sits below the git root and
422
- # prepend that. When base_dir *is* the git root (the Ruby suites, run from the repo
423
- # root), the prefix is empty and the original path is returned byte-for-byte — so this
424
- # only ever corrects a subdirectory base_dir and never rewrites a path that was
425
- # already right. Falls back to the raw path when there's no git checkout (an agent
426
- # running from a tarball) or base_dir sits outside one.
427
- def repo_relative_file(file, base_dir, cache = {})
428
- return file if file.nil? || file.empty?
429
-
430
- prefix = base_dir_prefix(base_dir, cache)
431
- return file if prefix.nil? || prefix.empty?
432
-
433
- File.join(prefix, file)
434
- end
435
-
436
- # base_dir's path relative to the git root that contains it: '' when base_dir is the
437
- # root, 'frontend' when it's a package below it, nil when base_dir isn't in a checkout
438
- # (or sits above the root). Cached per base_dir so a report shells out once.
439
- def base_dir_prefix(base_dir, cache)
440
- absolute = File.expand_path(base_dir)
441
- return cache[absolute] if cache.key?(absolute)
442
-
443
- root = git_toplevel_for(absolute)
444
- prefix =
445
- if root.nil?
446
- nil
447
- else
448
- rel = relative_path(absolute, root)
449
- rel == '.' || rel.start_with?('..') ? '' : rel
450
- end
451
- cache[absolute] = prefix
452
- end
453
-
454
- # The git repo root containing `dir`, or nil if `dir` isn't inside a checkout.
455
- def git_toplevel_for(dir)
456
- out = `git -C #{quote(dir)} rev-parse --show-toplevel 2>/dev/null`
457
- root = out.strip
458
- ($?&.success? && !root.empty?) ? File.expand_path(root) : nil
459
- end
460
-
461
- def relative_path(absolute, root)
462
- require 'pathname'
463
- Pathname.new(absolute).relative_path_from(Pathname.new(root)).to_s
464
- rescue ArgumentError
465
- # Different roots on Windows, or otherwise not relative — treat as outside.
466
- '..'
467
- end
468
-
469
- # Minimal shell-quote for a path passed to `git -C`. The directory comes from
470
- # --base-dir, not arbitrary report content, but quoting keeps spaces safe.
471
- def quote(str)
472
- "'#{str.gsub("'", "'\\\\''")}'"
473
- end
474
-
475
- # Scan file contents for fp:<slug>[@surface[,surface...]] markers, returning
476
- # [{ slug:, surfaces: [..], line:, title:, title_kind: }, ...] (1-based line numbers).
477
- # `surfaces` is [] when the marker pins none (use the default surface).
490
+ # Scan file contents for fp:<slug>[@surface[,surface...]][:type] markers, returning
491
+ # [{ slug:, surfaces: [..], test_type:, line:, title:, title_kind: }, ...]
492
+ # (1-based line numbers). `surfaces` is [] when the marker pins none (use the default
493
+ # surface). `test_type` is nil when the marker pins none (filed as `unspecified`).
478
494
  #
479
495
  # `title` is the name of the test (or describe block) declared below the marker, used
480
496
  # to bind markers to results from runners that report no line numbers. It is nil when
@@ -487,10 +503,11 @@ module Fp
487
503
  lines.each_with_index do |line, idx|
488
504
  next unless line.match?(COMMENT_LINE_RE)
489
505
 
490
- line.scan(MARKER_RE) do |(slug, surface_list)|
506
+ line.scan(MARKER_RE) do |(slug, surface_list, test_type)|
491
507
  surfaces = surface_list ? surface_list.split(',').map(&:strip).reject(&:empty?) : []
492
508
  title, kind = declared_test_after(lines, idx)
493
- markers << { slug: slug, surfaces: surfaces, line: idx + 1, title: title, title_kind: kind }
509
+ markers << { slug: slug, surfaces: surfaces, test_type: presence(test_type),
510
+ line: idx + 1, title: title, title_kind: kind }
494
511
  end
495
512
  end
496
513
  markers
data/lib/fp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fp
4
- VERSION = '0.0.14'
4
+ VERSION = '0.0.16'
5
5
  end
data/skills/fp/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: fp
3
- description: "Use the fp CLI to track feature parity: report evidence linking tests to requirements, propose new requirements as drafts, view the parity matrix, and upload test results from JUnit XML when CI isn't available."
3
+ description: "Use the fp CLI as a complete product specification: write decision-ready requirements, inventory and decompose scope, manage changes through review, bind tests to focused promises, report evidence, and inspect parity."
4
4
  license: MIT
5
5
  compatibility: Requires the fp executable on PATH (gem install featureparity)
6
6
  metadata:
@@ -41,17 +41,51 @@ If not set, ask the user for their API key or have them run `fp setup`.
41
41
 
42
42
  ## Core workflows
43
43
 
44
- ### 1. Check existing requirements
44
+ ### 1. Inventory and model the complete requirement scope
45
45
 
46
46
  ```bash
47
47
  fp list --project stowzilla # All active requirements
48
+ fp list --project stowzilla --status draft # Proposed work; avoid duplicates
48
49
  fp list --project stowzilla --gaps # Requirements with a required surface lacking evidence
49
- fp show <slug> --project stowzilla # Requirement details
50
+ fp show <slug> --project stowzilla # Requirement details and evidence
51
+ fp history <slug> --project stowzilla # Origins and pending changes
52
+ fp changes --source-url <work-item-url> # Activity prompted by one card, issue, or thread
50
53
  fp matrix --project stowzilla # ASCII parity matrix
51
54
  fp matrix --project stowzilla --csv # Export as CSV
52
55
  fp check --project stowzilla # Enforce parity (exits 1 on violations)
53
56
  ```
54
57
 
58
+ Treat FeatureParity as the human-readable product specification, not a bookkeeping step.
59
+ Before coding, inventory the entire request: user-visible behaviours, policies, limits,
60
+ plans, deadlines, state transitions, permissions, failure modes, and affected surfaces.
61
+ Compare all of it with active and draft requirements, including adjacent requirements the
62
+ change might make inaccurate.
63
+
64
+ Model that scope deliberately:
65
+
66
+ - One independently testable or independently failable product promise per focused
67
+ requirement. Split an acceptance sentence when its clauses could pass or fail separately.
68
+ - Use a parent for a broad capability and children for distinct behaviours, policies, or
69
+ surface-specific promises. Do not model implementation details with no product promise.
70
+ - Record concrete decisions such as prices, quotas, dates, roles, and fallbacks in acceptance
71
+ criteria rather than leaving them as assumptions in code or a pull request.
72
+ - Propose changes to existing requirements whose meaning moved; adding a new requirement is
73
+ not enough if it leaves an old one false.
74
+ - Reconcile again before finishing: every shipped behaviour maps to a current requirement,
75
+ every changed promise is recorded, and every focused requirement has marked evidence or
76
+ remains an explicit gap. Never let one broad marker imply coverage of untested clauses.
77
+
78
+ **Prevent contradictions before they enter the catalog.** Before proposing a new or changed
79
+ promise, compare its intended acceptance criteria with related active and draft requirements
80
+ and read their history. Ask whether both promises can be true at the same time for the same
81
+ user, state, and conditions. If not, do not add another requirement.
82
+
83
+ A failing test bound to an existing requirement, or an implementation that only works by
84
+ changing one, is a contradiction signal — not permission to weaken the test or write a second
85
+ requirement around it. Identify which promise matches product intent, then use
86
+ `fp propose-change` (or `--removal`) with the source and reason to put that decision before a
87
+ human. If the intent is unclear, stop and ask rather than making the catalog say both things.
88
+
55
89
  ### 2. Add the fp:<slug> marker to tests
56
90
 
57
91
  Place a comment immediately before the test to bind it to a requirement:
@@ -106,31 +140,82 @@ fp report --junit junit.xml \
106
140
  - `--surface` is the default; markers can override with `@suffix`
107
141
  - `--base-dir DIR` sets where relative paths resolve from
108
142
 
109
- ### 5. Propose new requirements
143
+ ### 5. Write and propose decision-ready requirements
144
+
145
+ A requirement is a product decision a reviewer can approve, not a design task for an
146
+ engineer to carry out. It should sound like a capable teammate wrote it: natural, direct,
147
+ and specific enough to protect an outcome without dictating an accidental solution.
148
+
149
+ Treat the catalog as the current record of shared understanding, not a finished specification
150
+ handed from a product owner to implementers. Requirements are discovered and refined through
151
+ ongoing conversation among customers, domain experts, and builders. The human product owner
152
+ makes the final value and priority decisions; agents participate by exposing gaps,
153
+ inconsistencies, alternatives, and useful splits. Prefer customer outcomes over devotion to a
154
+ feature list.
155
+
156
+ Before drafting:
157
+
158
+ - Learn from the request, related requirements and history, support reports, research, and
159
+ observed user behaviour when available. Look for what people attempt, where they abandon a
160
+ task, what they actually use, and what experiments reveal. That evidence may expose a
161
+ different need from the first solution they suggested.
162
+ - Separate facts and decided policy from open questions. Never turn a missing decision into
163
+ a plausible-sounding price, quota, timeout, count, date, role, or fallback. Ask when the
164
+ answer changes the product promise.
165
+ - Identify the actor, the situation that triggers the need, the valuable outcome, and material
166
+ boundaries such as permissions, failures, and affected surfaces.
167
+ - Keep one independently failable promise per requirement. Use a parent and focused children
168
+ when a capability contains several decisions.
169
+
170
+ Write each field for a reviewer, not for the implementation:
171
+
172
+ - **Title:** one clear product promise in present tense. Name the capability or outcome, not
173
+ the project (`Dispatchers see current shipment status`, not `Implement status polling`).
174
+ - **Why:** one or two natural sentences saying who needs this, the problem or risk, and the
175
+ outcome worth protecting. Do not repeat the title, advertise the feature, or explain how
176
+ it will be built. Delete AI filler such as “ensure a seamless, robust experience.”
177
+ - **Acceptance:** observable behaviour that lets someone decide whether the promise is true.
178
+ State the relevant trigger or situation, the result, and material boundaries. Include
179
+ failure behaviour, permissions, or cross-surface consistency when those affect the promise.
180
+ - **Examples:** use concrete examples or Given/When/Then cases when they clarify a rule or an
181
+ edge. Examples and executable tests ground and double-check the specification; they do not
182
+ define every case or replace the general rule and continued conversation.
183
+ - **Numbers and policy:** include a number only when the source made it a real product decision
184
+ or an external constraint requires it. Do not invent implementation defaults and present
185
+ them as scope.
186
+ - **Implementation detail:** omit components, data models, algorithms, endpoints, polling
187
+ intervals, and UI controls unless that interface or constraint is itself user-visible policy.
188
+
189
+ A good requirement is concise, not merely short. Keep every sentence that changes a decision;
190
+ remove everything that only sounds thorough.
191
+
192
+ - ✅ **Title:** `Dispatchers see current shipment status`
193
+ **Why:** `Dispatchers need to spot delayed shipments before they miss a delivery window.`
194
+ **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.`
195
+ - ❌ **Title:** `Implement Redis-backed 30-second status polling`
196
+ **Why:** `This important feature ensures a seamless, robust experience.`
197
+ **Acceptance:** `Use a background worker and refresh every 30 seconds.`
198
+ This invents policy, prescribes a design, and never states the outcome.
199
+
200
+ Before saving, read the draft as a manager deciding scope: Is the value clear? Can the result
201
+ be observed? Are real product decisions distinguished from guesses? Could every clause fail
202
+ independently and, if so, should it be split? Does every sentence earn its place? Revise or ask
203
+ before creating the record if the answer is no.
204
+
205
+ Then create the proposal as a draft:
110
206
 
111
207
  ```bash
112
208
  fp propose --project stowzilla \
113
- --slug print_container_qr \
114
- --name "Print QR on container label" \
115
- --why "Staff scan the label to find a container; without a QR they key the ID by hand and mis-type it" \
116
- --required api,customer_android \
117
- --acceptance "QR scans to the container's detail page"
209
+ --slug current_shipment_status \
210
+ --name "Dispatchers see current shipment status" \
211
+ --why "Dispatchers need to spot delayed shipments before they miss a delivery window" \
212
+ --required api,web \
213
+ --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" \
214
+ --source-type fizzy \
215
+ --source-url https://app.fizzy.do/123/cards/456
118
216
  ```
119
217
 
120
- **Writing `--why`: brief, and it actually explains why.** Whoever approves this
121
- requirement should grasp in one read why the product needs it — the user problem it
122
- solves or the outcome it protects. State that and stop. Longer is fine when it earns
123
- its length; wordy is not. Avoid two things: filler that says nothing specific, and
124
- describing *how* the feature works instead of *why* anyone wants it.
125
-
126
- - ✅ `--why "Staff scan the label to find a container; without a QR they key the ID by hand and mis-type it."`
127
- - ❌ `--why "Ensures a high-quality experience across all surfaces."` (filler — says nothing specific)
128
- - ❌ `--why "The label renders a QR image encoding the container ID at print time."` (how it works, not why anyone needs it)
129
-
130
- Same rule for `--acceptance`: the observable condition that proves it's done, not a
131
- restatement of the title.
132
-
133
- **Agents always create requirements as draft.** A human must activate in the web app.
218
+ **Agents always create requirements as draft.** A human must activate them in the web app.
134
219
 
135
220
  ### 6. Propose a change to an existing requirement
136
221
 
@@ -158,8 +243,9 @@ Rules:
158
243
 
159
244
  - Always pass `--reason` and, where one exists, `--source-type`/`--source-url`. The whole
160
245
  point of a proposal is that the *why* is recorded next to the *what*.
161
- - Agents cannot change `--slug` (it's the join to every `fp:<slug>` marker) or remove a
162
- required surface. Both come back as a 403, printed verbatim.
246
+ - Agents may correct `--name` (the title) or `--slug` through a proposal. Existing
247
+ evidence follows a proposed slug, but every `fp:<slug>` marker still needs the same
248
+ correction. Agents still cannot remove a required surface.
163
249
  - Agents cannot approve or reject. Only humans review.
164
250
  - One open proposal per requirement — review the current one before proposing another.
165
251
 
@@ -247,6 +333,7 @@ fp init --project <slug> --surface <surface>
247
333
  | Propose a change | `fp propose-change <slug> --project X --why "..." --reason "..."` |
248
334
  | Propose a deletion | `fp propose-change <slug> --project X --removal --reason "..."` |
249
335
  | View change history | `fp history <slug> --project X` |
336
+ | View work-item activity | `fp changes --project X --source-url URL` |
250
337
  | Enforce parity | `fp check --project X` |
251
338
  | Gate on this run | `fp check --project X --surface Y --junit file.xml` |
252
339
  | View matrix | `fp matrix --project X` |