@mmerterden/multi-agent-pipeline 16.11.0 → 16.12.0

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 (33) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +5 -3
  3. package/README.tr.md +5 -3
  4. package/docs/adr/0001-three-model-triage.md +5 -0
  5. package/docs/features.md +18 -2
  6. package/package.json +1 -1
  7. package/pipeline/claude-md-template.md +1 -1
  8. package/pipeline/commands/multi-agent/SKILL.md +1 -1
  9. package/pipeline/commands/multi-agent/analysis/SKILL.md +1 -1
  10. package/pipeline/commands/multi-agent/help/SKILL.md +2 -2
  11. package/pipeline/commands/multi-agent/resume-local/SKILL.md +2 -2
  12. package/pipeline/commands/multi-agent/review/SKILL.md +3 -3
  13. package/pipeline/commands/multi-agent/review-analysis/SKILL.md +1 -1
  14. package/pipeline/lib/figma-screenshot.sh +107 -7
  15. package/pipeline/lib/md2confluence-v3.py +133 -25
  16. package/pipeline/multi-agent-refs/analysis/locked.md +4 -3
  17. package/pipeline/multi-agent-refs/analysis/render.md +44 -9
  18. package/pipeline/multi-agent-refs/analysis/review.md +17 -1
  19. package/pipeline/multi-agent-refs/analysis-template-corporate.md +14 -3
  20. package/pipeline/multi-agent-refs/knowledge.md +1 -1
  21. package/pipeline/multi-agent-refs/phases/phase-4-review.md +8 -8
  22. package/pipeline/schemas/analysis-spec.schema.json +2 -0
  23. package/pipeline/schemas/reviewer-output.schema.json +1 -1
  24. package/pipeline/schemas/triage-output.schema.json +1 -1
  25. package/pipeline/scripts/anonymize-findings.mjs +1 -1
  26. package/pipeline/scripts/smoke-cross-cli-behavior.sh +9 -9
  27. package/pipeline/scripts/validate-analysis-doc.mjs +85 -23
  28. package/pipeline/skills/.skills-index.json +2 -2
  29. package/pipeline/skills/shared/README.md +1 -1
  30. package/pipeline/skills/shared/core/multi-agent/SKILL.md +3 -3
  31. package/pipeline/skills/shared/core/multi-agent-help/SKILL.md +2 -2
  32. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +2 -2
  33. package/pipeline/skills/skills-index.md +1 -1
@@ -134,6 +134,16 @@ def parse_front_matter(text: str) -> tuple[dict[str, str], str]:
134
134
  MERMAID_FENCE_OPEN_RE = re.compile(r"^```mermaid\s*$")
135
135
 
136
136
 
137
+ def code_macro(lang: str, body: str) -> str:
138
+ """A plain code macro, used to carry mermaid source next to a lossy fallback."""
139
+ return (
140
+ '<ac:structured-macro ac:name="code">'
141
+ f'<ac:parameter ac:name="language">{html.escape(lang, quote=True)}</ac:parameter>'
142
+ f'<ac:plain-text-body><![CDATA[\n{cdata_escape(body)}\n]]></ac:plain-text-body>'
143
+ '</ac:structured-macro>'
144
+ )
145
+
146
+
137
147
  def mermaid_macro(body: str) -> str:
138
148
  return (
139
149
  '<ac:structured-macro ac:name="mermaid">'
@@ -145,18 +155,32 @@ def mermaid_macro(body: str) -> str:
145
155
  def mermaid_to_numbered_list(body: str) -> str:
146
156
  """Best-effort flowchart node extraction for fallback rendering."""
147
157
  nodes: list[str] = []
148
- edges: list[tuple[str, str]] = []
158
+ edges: list[tuple[str, str, str]] = []
149
159
  node_label: dict[str, str] = {}
150
160
  node_pattern = re.compile(r"([A-Za-z0-9_]+)\s*(?:\[([^\]]+)\]|\(([^)]+)\)|\{([^}]+)\})")
151
- edge_pattern = re.compile(r"([A-Za-z0-9_]+)\s*-->\s*([A-Za-z0-9_]+)")
161
+ # `A --> B` was the only shape matched, so every labelled branch
162
+ # (`B -- Evet --> C`, `B -->|Hayir| D`) lost both its edge and its label, which
163
+ # is the part of a flow chart that carries the decision. Both label syntaxes are
164
+ # captured now and the label is kept.
165
+ # A node id is usually followed by its shape and label - A[Basla], B{Karar} -
166
+ # so the id is not adjacent to the arrow. The old pattern required adjacency and
167
+ # therefore matched no edge at all in any diagram whose nodes carry labels, which
168
+ # is every real one. Skip an optional [..] (..) {..} on both sides.
169
+ _SHAPE = r"(?:\[[^\]]*\]|\([^)]*\)|\{[^}]*\})?"
170
+ edge_pattern = re.compile(
171
+ r"([A-Za-z0-9_]+)" + _SHAPE + r"\s*"
172
+ r"(?:--\s*(?P<lbl_dash>[^->|]+?)\s*-->|-->\s*\|(?P<lbl_pipe>[^|]*)\||-->)"
173
+ r"\s*([A-Za-z0-9_]+)" + _SHAPE
174
+ )
152
175
 
153
176
  for raw in body.splitlines():
154
177
  line = raw.strip()
155
178
  if not line or line.startswith("%%"):
156
179
  continue
157
180
  for em in edge_pattern.finditer(line):
158
- a, b = em.group(1), em.group(2)
159
- edges.append((a, b))
181
+ a, b = em.group(1), em.group(4)
182
+ label = (em.group("lbl_dash") or em.group("lbl_pipe") or "").strip()
183
+ edges.append((a, b, label))
160
184
  for nm in (a, b):
161
185
  if nm not in node_label:
162
186
  node_label[nm] = nm
@@ -174,9 +198,9 @@ def mermaid_to_numbered_list(body: str) -> str:
174
198
  # Could not parse; emit as plain pre block
175
199
  return f"<pre>{html.escape(body)}</pre>"
176
200
 
177
- next_map: dict[str, list[str]] = {}
178
- for a, b in edges:
179
- next_map.setdefault(a, []).append(b)
201
+ next_map: dict[str, list[tuple[str, str]]] = {}
202
+ for a, b, lbl in edges:
203
+ next_map.setdefault(a, []).append((b, lbl))
180
204
 
181
205
  items: list[str] = []
182
206
  for idx, key in enumerate(nodes, start=1):
@@ -184,7 +208,13 @@ def mermaid_to_numbered_list(body: str) -> str:
184
208
  nxt = next_map.get(key, [])
185
209
  suffix = ""
186
210
  if nxt:
187
- suffix = " next: " + ", ".join(node_label.get(n, n) for n in nxt)
211
+ # "next: Evet -> Odeme, Hayir -> Hata" keeps the branch condition, which
212
+ # is the only reason a reader looks at a decision node.
213
+ parts = [
214
+ (f"{lbl} -> {node_label.get(n, n)}" if lbl else node_label.get(n, n))
215
+ for n, lbl in nxt
216
+ ]
217
+ suffix = " next: " + ", ".join(parts)
188
218
  items.append(f"<li>{html.escape(label)}{html.escape(suffix)}</li>")
189
219
  return "<ol>" + "".join(items) + "</ol>"
190
220
 
@@ -223,12 +253,33 @@ def cdata_escape(s: str) -> str:
223
253
  class InlineContext:
224
254
  attachments_available: set[str]
225
255
  tooltip_macro_supported: bool
256
+ image_width: int = 0
226
257
  warnings: list[str] = field(default_factory=list)
227
258
  tooltip_fallback_used: bool = False
228
259
  images_referenced: list[str] = field(default_factory=list)
229
260
 
230
261
 
262
+ # A 2x export is 750x1624 for a phone frame and 2880x2048 for a desktop one, and
263
+ # Confluence renders an <ac:image> at its natural size, so one screenshot took two
264
+ # screen heights on the page. Width is a display attribute only: the attachment
265
+ # stays full resolution and a click still opens the original.
266
+ # 720 is a desktop frame at half of a 2x export and a diagram at readable size;
267
+ # a phone frame overrides itself down to 320 through the title syntax, which the
268
+ # frame-gallery row writes because it is the only place that knows the frame width.
269
+ DEFAULT_IMAGE_WIDTH = 720
270
+
271
+ IMAGE_WIDTH_RE = re.compile(r"\bwidth\s*=\s*(\d{2,4})\b")
272
+
273
+
231
274
  def render_image(alt: str, src: str, ctx: InlineContext) -> str:
275
+ # Markdown title syntax carries the per-image override: ![alt](f.png "width=320")
276
+ width = None
277
+ m = re.match(r'^(?P<path>\S+)\s+"(?P<title>[^"]*)"$', src.strip())
278
+ if m:
279
+ src = m.group("path")
280
+ w = IMAGE_WIDTH_RE.search(m.group("title"))
281
+ if w:
282
+ width = int(w.group(1))
232
283
  filename = os.path.basename(src)
233
284
  # If src is an http(s) URL, render as external image link.
234
285
  if re.match(r"^https?://", src):
@@ -237,9 +288,12 @@ def render_image(alt: str, src: str, ctx: InlineContext) -> str:
237
288
  if ctx.attachments_available and filename not in ctx.attachments_available:
238
289
  ctx.warnings.append(f"image reference has no matching attachment: {filename}")
239
290
  return f'<a href="{html.escape(src, quote=True)}">{esc(alt) or esc(filename)}</a>'
291
+ if width is None:
292
+ width = ctx.image_width
240
293
  alt_attr = f' ac:alt="{html.escape(alt, quote=True)}"' if alt else ""
294
+ width_attr = f' ac:width="{width}"' if width else ""
241
295
  return (
242
- f"<ac:image{alt_attr}>"
296
+ f"<ac:image{alt_attr}{width_attr}>"
243
297
  f'<ri:attachment ri:filename="{html.escape(filename, quote=True)}" />'
244
298
  "</ac:image>"
245
299
  )
@@ -339,10 +393,12 @@ def markdown_to_storage(
339
393
  attachments_available: set[str],
340
394
  mermaid_fallback: bool,
341
395
  tooltip_macro_supported: bool,
396
+ image_width: int = 0,
342
397
  ) -> ConvertResult:
343
398
  ctx = InlineContext(
344
399
  attachments_available=attachments_available,
345
400
  tooltip_macro_supported=tooltip_macro_supported,
401
+ image_width=image_width,
346
402
  )
347
403
  lines = markdown.split("\n")
348
404
  out: list[str] = []
@@ -379,7 +435,11 @@ def markdown_to_storage(
379
435
  body = "\n".join(mermaid_buf)
380
436
  if mermaid_fallback:
381
437
  mermaid_fallback_used = True
438
+ # The list is lossy however carefully it is built, so the source
439
+ # goes with it. A reader who needs the real diagram can paste it
440
+ # anywhere that renders mermaid; without it the diagram is gone.
382
441
  out.append(mermaid_to_numbered_list(body))
442
+ out.append(code_macro("text", body))
383
443
  else:
384
444
  out.append(mermaid_macro(body))
385
445
  in_mermaid = False
@@ -643,6 +703,17 @@ def upload_attachment(
643
703
  """Attach file to page. Uses POST .../child/attachment, which overwrites if
644
704
  Confluence is configured to accept the same filename. Wrapped in retry so
645
705
  transient 5xx/429/network errors recover before the call site sees them."""
706
+ # Look before creating. Re-attaching a filename that is already on the page is
707
+ # a duplicate, and which status a Confluence tells you that with is not a
708
+ # constant: Cloud answers 400, this Server answers 500. Keying the
709
+ # already-exists path off a status code meant the update path was unreachable
710
+ # on Server, so every re-run logged one warning per attachment while the files
711
+ # were in fact fine. Asking first removes the guess, and leaves the error path
712
+ # as what it should have been all along: a race, not the normal case.
713
+ existing = find_existing_attachment(auth, page_id, file_path.name)
714
+ if existing:
715
+ return update_attachment_data(auth, page_id, existing, file_path)
716
+
646
717
  data = file_path.read_bytes()
647
718
  body, content_type = build_multipart([("file", data, file_path.name)])
648
719
  url = f"{auth.base_url}/rest/api/content/{page_id}/child/attachment"
@@ -661,23 +732,23 @@ def upload_attachment(
661
732
  def _do() -> tuple[bool, str]:
662
733
  with urllib.request.urlopen(req) as resp:
663
734
  resp.read()
664
- return True, "ok"
735
+ return True, "created"
665
736
 
666
737
  try:
667
738
  return _retry_http(_do)
668
739
  except urllib.error.HTTPError as e:
669
- # If attachment already exists, retry against the existing attachment id.
670
- if e.code == 400:
740
+ # Race: something attached the same filename between the check and here.
741
+ # Both codes are treated the same because both mean the same thing.
742
+ if e.code in (400, 409, 500):
743
+ existing = find_existing_attachment(auth, page_id, file_path.name)
744
+ if existing:
745
+ return update_attachment_data(auth, page_id, existing, file_path)
671
746
  err_body = ""
672
747
  try:
673
748
  err_body = e.read().decode("utf-8", errors="replace")
674
749
  except Exception:
675
750
  pass
676
- existing = find_existing_attachment(auth, page_id, file_path.name)
677
- if existing:
678
- ok, msg = update_attachment_data(auth, page_id, existing, file_path)
679
- return ok, msg
680
- return False, f"HTTP 400: {err_body[:300]}"
751
+ return False, f"HTTP {e.code}: {err_body[:300]}"
681
752
  return False, f"HTTP {e.code}"
682
753
  except urllib.error.URLError as e:
683
754
  return False, f"URLError: {e.reason}"
@@ -688,19 +759,24 @@ def upload_attachments_parallel(
688
759
  page_id: str,
689
760
  attachments: dict[str, Path],
690
761
  max_workers: int = 4,
691
- ) -> tuple[int, list[str]]:
762
+ ) -> tuple[int, int, list[str]]:
692
763
  """Upload many attachments concurrently via a small thread pool.
693
764
 
694
- Returns (success_count, warning_messages). Sequential ordering is not
765
+ Returns (created_count, updated_count, warning_messages). Created and updated
766
+ are counted apart because they are not the same event: a re-run of the same
767
+ document updates every attachment and creates none, and reporting that as
768
+ "0 uploaded, 12 warnings" reads as data loss when nothing was lost.
769
+ Sequential ordering is not
695
770
  preserved (the prior implementation processed `sorted(referenced)`), but
696
771
  that ordering had no semantic meaning - it was just deterministic for
697
772
  debugging. The thread pool keeps per-task retry semantics intact via
698
773
  upload_attachment().
699
774
  """
700
775
  if not attachments:
701
- return 0, []
776
+ return 0, 0, []
702
777
  warnings: list[str] = []
703
- uploaded = 0
778
+ created = 0
779
+ updated = 0
704
780
  workers = max(1, min(max_workers, len(attachments)))
705
781
  with ThreadPoolExecutor(max_workers=workers) as executor:
706
782
  future_to_name = {
@@ -712,12 +788,15 @@ def upload_attachments_parallel(
712
788
  try:
713
789
  ok, msg = future.result()
714
790
  if ok:
715
- uploaded += 1
791
+ if msg == "updated":
792
+ updated += 1
793
+ else:
794
+ created += 1
716
795
  else:
717
796
  warnings.append(f"attachment upload failed for {name}: {msg}")
718
797
  except Exception as e:
719
798
  warnings.append(f"attachment upload exception for {name}: {e}")
720
- return uploaded, warnings
799
+ return created, updated, warnings
721
800
 
722
801
 
723
802
  def find_existing_attachment(
@@ -829,6 +908,7 @@ def build_storage(
829
908
  attachments_dir: Path | None,
830
909
  mermaid_fallback: bool,
831
910
  tooltip_macro_supported: bool = True,
911
+ image_width: int = DEFAULT_IMAGE_WIDTH,
832
912
  ) -> tuple[ConvertResult, dict[str, str], dict[str, Path], list[str]]:
833
913
  front_matter, body = parse_front_matter(markdown_text)
834
914
  language = detect_language(body, front_matter.get("language"))
@@ -839,6 +919,7 @@ def build_storage(
839
919
  attachments_available=set(attachments.keys()),
840
920
  mermaid_fallback=mermaid_fallback,
841
921
  tooltip_macro_supported=tooltip_macro_supported,
922
+ image_width=image_width,
842
923
  )
843
924
  result.warnings = punct_warnings + result.warnings
844
925
  return result, front_matter, attachments, []
@@ -855,6 +936,7 @@ def cmd_create(args: argparse.Namespace) -> int:
855
936
  md_text,
856
937
  attachments_dir=attachments_dir,
857
938
  mermaid_fallback=args.mermaid_fallback,
939
+ image_width=getattr(args, "image_width", DEFAULT_IMAGE_WIDTH),
858
940
  )
859
941
 
860
942
  if args.dry_run:
@@ -864,6 +946,7 @@ def cmd_create(args: argparse.Namespace) -> int:
864
946
  "page_id": None,
865
947
  "page_url": None,
866
948
  "attachments_uploaded": 0,
949
+ "attachments_updated": 0,
867
950
  "attachments_available": len(attachments),
868
951
  "mermaid_macro_fallback_used": result.mermaid_fallback_used,
869
952
  "tooltip_macro_fallback_used": result.tooltip_fallback_used,
@@ -893,6 +976,7 @@ def cmd_create(args: argparse.Namespace) -> int:
893
976
  md_text,
894
977
  attachments_dir=attachments_dir,
895
978
  mermaid_fallback=True,
979
+ image_width=getattr(args, "image_width", DEFAULT_IMAGE_WIDTH),
896
980
  )
897
981
  create_payload["body"]["storage"]["value"] = result.storage_xml
898
982
  status, body, raw = http_json(auth, "POST", "/rest/api/content", create_payload)
@@ -905,6 +989,7 @@ def cmd_create(args: argparse.Namespace) -> int:
905
989
 
906
990
  # 2. Upload attachments referenced by markdown - parallel thread pool.
907
991
  uploaded = 0
992
+ updated = 0
908
993
  if attachments and result.images_referenced:
909
994
  to_upload = {
910
995
  name: attachments[name]
@@ -912,7 +997,9 @@ def cmd_create(args: argparse.Namespace) -> int:
912
997
  if name in attachments
913
998
  }
914
999
  if to_upload:
915
- uploaded, upload_warnings = upload_attachments_parallel(auth, page_id, to_upload)
1000
+ uploaded, updated, upload_warnings = upload_attachments_parallel(
1001
+ auth, page_id, to_upload
1002
+ )
916
1003
  warnings.extend(upload_warnings)
917
1004
 
918
1005
  # 3. Apply labels from front matter.
@@ -925,6 +1012,7 @@ def cmd_create(args: argparse.Namespace) -> int:
925
1012
  "page_id": page_id,
926
1013
  "page_url": page_url,
927
1014
  "attachments_uploaded": uploaded,
1015
+ "attachments_updated": updated,
928
1016
  "attachments_available": len(attachments),
929
1017
  "mermaid_macro_fallback_used": result.mermaid_fallback_used,
930
1018
  "tooltip_macro_fallback_used": result.tooltip_fallback_used,
@@ -941,6 +1029,7 @@ def cmd_update(args: argparse.Namespace) -> int:
941
1029
  md_text,
942
1030
  attachments_dir=attachments_dir,
943
1031
  mermaid_fallback=args.mermaid_fallback,
1032
+ image_width=getattr(args, "image_width", DEFAULT_IMAGE_WIDTH),
944
1033
  )
945
1034
 
946
1035
  if args.dry_run:
@@ -950,6 +1039,7 @@ def cmd_update(args: argparse.Namespace) -> int:
950
1039
  "page_id": args.page_id,
951
1040
  "page_url": None,
952
1041
  "attachments_uploaded": 0,
1042
+ "attachments_updated": 0,
953
1043
  "attachments_available": len(attachments),
954
1044
  "mermaid_macro_fallback_used": result.mermaid_fallback_used,
955
1045
  "tooltip_macro_fallback_used": result.tooltip_fallback_used,
@@ -989,6 +1079,7 @@ def cmd_update(args: argparse.Namespace) -> int:
989
1079
  md_text,
990
1080
  attachments_dir=attachments_dir,
991
1081
  mermaid_fallback=True,
1082
+ image_width=getattr(args, "image_width", DEFAULT_IMAGE_WIDTH),
992
1083
  )
993
1084
  update_payload["body"]["storage"]["value"] = result.storage_xml
994
1085
  status, body, raw = http_json(
@@ -1003,6 +1094,7 @@ def cmd_update(args: argparse.Namespace) -> int:
1003
1094
  page_url = page_web_url(auth, body, page_id)
1004
1095
 
1005
1096
  uploaded = 0
1097
+ updated = 0
1006
1098
  if attachments and result.images_referenced:
1007
1099
  to_upload = {
1008
1100
  name: attachments[name]
@@ -1010,7 +1102,9 @@ def cmd_update(args: argparse.Namespace) -> int:
1010
1102
  if name in attachments
1011
1103
  }
1012
1104
  if to_upload:
1013
- uploaded, upload_warnings = upload_attachments_parallel(auth, page_id, to_upload)
1105
+ uploaded, updated, upload_warnings = upload_attachments_parallel(
1106
+ auth, page_id, to_upload
1107
+ )
1014
1108
  warnings.extend(upload_warnings)
1015
1109
 
1016
1110
  labels = build_labels(front_matter)
@@ -1022,6 +1116,7 @@ def cmd_update(args: argparse.Namespace) -> int:
1022
1116
  "page_id": page_id,
1023
1117
  "page_url": page_url,
1024
1118
  "attachments_uploaded": uploaded,
1119
+ "attachments_updated": updated,
1025
1120
  "attachments_available": len(attachments),
1026
1121
  "mermaid_macro_fallback_used": result.mermaid_fallback_used,
1027
1122
  "tooltip_macro_fallback_used": result.tooltip_fallback_used,
@@ -1065,6 +1160,13 @@ def build_parser() -> argparse.ArgumentParser:
1065
1160
  create.add_argument("--markdown", required=True)
1066
1161
  create.add_argument("--attachments-dir")
1067
1162
  create.add_argument("--mermaid-fallback", action="store_true")
1163
+ create.add_argument(
1164
+ "--image-width",
1165
+ type=int,
1166
+ default=DEFAULT_IMAGE_WIDTH,
1167
+ help="Display width in px for embedded attachment images (0 = natural size). "
1168
+ "Per-image override: ![alt](f.png \"width=320\").",
1169
+ )
1068
1170
  create.add_argument("--dry-run", action="store_true")
1069
1171
  create.set_defaults(func=cmd_create)
1070
1172
 
@@ -1073,6 +1175,12 @@ def build_parser() -> argparse.ArgumentParser:
1073
1175
  update.add_argument("--title", required=True)
1074
1176
  update.add_argument("--markdown", required=True)
1075
1177
  update.add_argument("--attachments-dir")
1178
+ update.add_argument(
1179
+ "--image-width",
1180
+ type=int,
1181
+ default=DEFAULT_IMAGE_WIDTH,
1182
+ help="Display width in px for embedded attachment images (0 = natural size).",
1183
+ )
1076
1184
  update.add_argument("--mermaid-fallback", action="store_true")
1077
1185
  update.add_argument("--dry-run", action="store_true")
1078
1186
  update.set_defaults(func=cmd_update)
@@ -29,14 +29,14 @@ When citing a Locked decision in code or docs, prefer `Locked <n> (<short label>
29
29
  9. **Per-platform output split.** One markdown file is produced per selected platform under `analysis/<feature>-<platform>.md`. There is no merged single file. Section duplication follows the A3 hybrid table in `$HOME/.claude/multi-agent-refs/analysis-template.md` (Sections 1 + 4 duplicated verbatim; Sections 2, 3, 5, 6, 7 projected per platform). Each file carries a YAML front-matter header naming the feature, platform, generated-at timestamp, sibling file list, and binding standards source.
30
30
  10. **Output destination is asked after drafts exist.** Phase 3 renders each per-platform markdown to `/tmp/analysis-<feature-slug>-<timestamp>/` first. Only then does Phase 3.5 surface the output-destination picker (Local / Confluence / Jira). Drafts in `/tmp/` are the source of truth on resume; `/multi-agent:resume` re-uses them when `phase == awaiting_output_decision`.
31
31
  11. **Repo-evidence reuse-first.** Phase 1b runs a repo-evidence collector against each selected repo, producing 13 buckets (services, dtos, useCases, validationRules, domainEntities, routes, coordinators, diConfigurators, uiComponents, tokens, localizationKeys, testingIdentifiers, analyticsEvents) with each row tagged `direct-match | same-domain | cross-cutting`. Section 7 emits `Reuse existing X (file:line)` rows for `direct-match` items and advisory rows for `cross-cutting` items. New-write tasks for items that have a `direct-match` are downgraded to Risks ("existing X candidate found; reuse or document why a new one is needed").
32
- 12. **MUST: Figma access - 3-tier fallback chain, pipeline-wide.** When any Figma URL or node ID is supplied (Phase 0 Step 5), Phase 1 establishes a Figma ground-truth artefact via the 3-tier chain before any UI synthesis runs: (1) Figma MCP `get_design_context` / `get_screenshot` / `get_metadata` for every referenced frame, with one re-auth retry on auth failure; (2) Figma REST API (`GET /v1/files/{fileKey}/nodes`, `GET /v1/images/{fileKey}`) using the PAT resolved through `~/.claude/lib/credential-store.sh get <logical-key>` where `<logical-key>` = `prefs.global.keychainMapping.figma`; (3) a user-attached screenshot as last resort. The tier in use is persisted as `state.figmaAccess.tier`. On Tier 1 the `CodeConnectSnippet` blocks name the exact target component consumed verbatim in Section 2 and Section 7. On Tier 2 the canonical-component decision falls back to the repo's `*.figma.swift` / `*.figma.kt` mappings keyed by `fileKey` + `nodeId`. On Tier 3 the canonical-component decision becomes "tier-3 best-fit pending design review" and produces a forced Open Question in Section 7 plus a Phase 4 `review_blocking` flag. Sound-alike alternatives, "more flexible" wrappers, and extrapolating from Confluence text-only "Component Kompozisyonu" / "Component Inventory" tables are forbidden in every tier. If all three tiers fail, halt the run and ask the user for access; never proceed with text-derived guesses. Section 7 architecture decisions must cite the node ID (Tier 1 or Tier 2) or the user-screenshot reference (Tier 3) for every UI atom row. Violations cost rebuild rounds (raw-primitive substitution; sound-alike-component swap). Generic rule rationale and checklist: see `$HOME/.claude/rules/figma-pipeline.md` "MUST: Figma access - 3-tier fallback chain (BLOCKING, pipeline-wide)". Memory: `[[figma-no-guesswork]]`.
32
+ 12. **MUST: Figma access - 3-tier fallback chain, pipeline-wide.** When any Figma URL or node ID is supplied (Phase 0 Step 5), Phase 1 establishes a Figma ground-truth artefact via the 3-tier chain before any UI synthesis runs: (1) Figma MCP `get_design_context` / `get_screenshot` / `get_metadata` for every referenced frame, with one re-auth retry on auth failure; (2) Figma REST API (`GET /v1/files/{fileKey}/nodes`, `GET /v1/images/{fileKey}`) using the PAT resolved through `~/.claude/lib/credential-store.sh get <logical-key>` where `<logical-key>` = `prefs.global.keychainMapping.figma`; (3) a user-attached screenshot as last resort. The tier in use is persisted as `state.figmaAccess.tier`. On Tier 1 the `CodeConnectSnippet` blocks name the exact target component consumed verbatim in Section 2 and Section 7. On Tier 2 the canonical-component decision falls back to the repo's `*.figma.swift` / `*.figma.kt` mappings keyed by `fileKey` + `nodeId`. On Tier 3 the canonical-component decision becomes "tier-3 best-fit pending design review" and produces a forced Open Question in Section 7 plus a Phase 4 `review_blocking` flag. Sound-alike alternatives, "more flexible" wrappers, and extrapolating from Confluence text-only "Component Kompozisyonu" / "Component Inventory" tables are forbidden in every tier. If all three tiers fail, halt the run and ask the user for access; never proceed with text-derived guesses. Section 7 architecture decisions must cite the node ID (Tier 1 or Tier 2) or the user-screenshot reference (Tier 3) for every UI atom row. Violations cost rebuild rounds (raw-primitive substitution; sound-alike-component swap). Generic rule rationale and checklist: see `$HOME/.claude/rules/figma-pipeline.md` "MUST: Figma access - 3-tier fallback chain (BLOCKING, pipeline-wide)". Memory: `[[figma-no-guesswork]]`. **A channel's design may not be called missing until the file tree has been scanned.** Run `figma-screenshot.sh --discover-sections --file-key <k> --feature-slug <s>`; sibling sections it returns join `evidence.figma[]` as their own entries and are never re-asked (Locked 1). Only an empty `candidates` list lets a channel be recorded as having no design, and the record must cite the scan (`fileKey`, `pagesScanned`, `tokensTried`). Declaring a channel missing without scanning fails the dispatch gate: one run did, and the sibling was sitting in the same file with nine frames in it.
33
33
  13. **Gherkin user stories.** Section 4 scenarios use Given / When / Then. Plain-prose user stories are rejected at render time.
34
34
  14. **Goals and Non-Goals are paired.** Section 2 always carries both columns. A row in only the goal column without an explicit non-goal counterpart is rejected. Vague "out of scope" phrasing does not satisfy the non-goal column; each non-goal names what is excluded.
35
35
  15. **New assets default to SVG.** Section 8 entries marked `new` are SVG unless a documented exception is captured in the rationale column (Lottie for motion design, optimized PNG for raster-only icons). PDF, JPG, and unoptimized PNG are rejected.
36
36
  16. **Files-to-Add tag mandatory.** Every row in Section 14 carries one tag from `Reuse | Add new | Modify`. Untagged rows fail the dispatch gate.
37
37
  17. **API response variants exhaustive.** Section 9 lists every HTTP status code returned by the endpoint with at least one example body and the matching UI outcome. Phrases like "other errors" or "various 4xx" are rejected.
38
- 18. **Screenshots embedded, not linked.** Section 5 frame galleries reference local PNG files. Dispatch uploads them as Confluence multipart attachments and injects `<ac:image><ri:attachment ri:filename="..." /></ac:image>` into the page body. URL-only Figma references in Section 5 are a review finding, not a validator ERROR: the check belongs to the Confluence dispatch step, which is the only place that knows whether an attachment upload succeeded.
39
- 19. **All Figma variants drilled.** When a Figma section URL is supplied, all child frames are drilled, not just the canonical default. The renderer enumerates child frames via `mcp__claude_ai_Figma__get_metadata` (Tier 1) or `figma-screenshot.sh --section` (Tier 2) and produces one Section 5.1 row per child frame.
38
+ 18. **Screenshots embedded, not linked, and every inventory row gets one.** Section 5 frame galleries reference local PNG files. Dispatch uploads them as Confluence multipart attachments and injects `<ac:image><ri:attachment ri:filename="..." /></ac:image>` into the page body. The gallery is generated from the inventory, never hand-picked: one row, one embedded image, ordered by node id and titled `<nodeId> <frame name>`. A bare filename in a table cell is text to the reader, not a picture, which is how a run that uploaded 34 attachments and embedded 14 of them was reported as "frames not uploaded" - the files were there and invisible. Narrowing to a canonical subset for page weight is not a call the run makes on its own; the display-width cap (`md2confluence-v3.py --image-width`, phone frames overriding to 320) already solves readability without dropping evidence. Dispatch reports the embedded-over-inventory ratio and warns below 1. URL-only Figma references in Section 5 are a review finding, not a validator ERROR: the check belongs to the Confluence dispatch step, which is the only place that knows whether an attachment upload succeeded.
39
+ 19. **All Figma variants drilled, and drilled means read.** When a Figma section URL is supplied, all child frames are drilled, not just the canonical default. The renderer enumerates child frames via `mcp__claude_ai_Figma__get_metadata` (Tier 1) or `figma-screenshot.sh --section` (Tier 2) and produces one Section 5.1 row per child frame. Counting them is not drilling them: extract the visible `TEXT` layers inside each frame's own bounds, dropping hidden layers and component-instance boilerplate that overflows the frame (in one run a frame carried 88 text layers of which 13 were real, the rest default flight-card and currency-widget filler). A frame may be called out of scope only by quoting its own extracted text; "the name looked unrelated" is not a reason. Opening a Section 20 question about a frame requires that its text was extracted first, and asking about a frame nobody read fails the dispatch gate. One run did exactly that: a frame named `08 Payment` was logged as ambiguous and questioned, while its text spelled out the whole payment step, and the use case shipped incomplete.
40
40
  20. **Localization mode (ownership-aware).** Section 10's shape is driven by the project `figma-config` `localization.ownership` (default `in-repo`). The locale set comes from `localization.locales` (default `tr, en, ar, de, es, fr, it, ru`); do not hardcode a locale list in the render.
41
41
  - **`in-repo`** (default, historical behavior): new keys carry filled cells for every configured locale. Placeholder values `[bekleniyor: çeviri ekibi]` / `[pending: translation team]` are a soft state and the dispatch report flags `i18n_pending: <count>` as a blocker. Empty cells are rejected outright.
42
42
  - **`externally-owned`**: per-locale values are owned by the authoring system named in `localization.authoringPipeline` and MUST NOT be hand-filled. Section 10 lists `key | status | copy source | base value | ownership` only; the gate becomes "the `localization.baseLanguage` value is present (sourced from the Figma annotation, Locked 3) AND the ownership reference is cited". A key missing its base-language value is a blocker (it renders the raw key at runtime). Fabricated per-locale values are rejected.
@@ -56,3 +56,4 @@ When citing a Locked decision in code or docs, prefer `Locked <n> (<short label>
56
56
  33. **Corporate backbone always renders.** In the `corporate` profile the Locked 2 omission rule is replaced for Part A and the footer: those sections render even with zero evidence, carrying `N/A` when the section is genuinely out of scope for the feature and `EKLENECEK` when evidence is expected but missing. This is the point of a requirements document - a reader has to be able to tell "we considered hardware needs and there are none" from "nobody looked". Every `EKLENECEK` emits a matching Section 20 Risks and Open Questions row naming what is missing and who can answer it; an `EKLENECEK` with no such row fails the dispatch gate, because an unanswered question nobody owns is how a placeholder reaches production. **Missing inputs never block the run**: the corporate source practice of halting until every input arrives is deliberately not adopted - the document is produced with `EKLENECEK` in the gaps and the gaps are raised in Section 20. Part B follows the global omission table unchanged. In the `global` profile Locked 2 applies as written, with no placeholder of any kind.
57
57
  34. **References are built from the evidence record, not written.** Section 21 is emitted by `$HOME/.claude/scripts/build-references.mjs` from `state.analysisSpec.evidence.*` in both profiles. Each row carries a precision anchor in its `Sürüm / Ref` column - Figma node id, Confluence `pageId` plus page version, the commit SHA a repo was read at, the Swagger spec version - because a reference with no anchor points at a moving target. Each row carries an `Erişim / Access` cell: a declared source that could not be fetched still gets a row reading `erişilemedi (<reason>)`, since a silently dropped source reads to the next person as a source that never existed. User statements from the conversation that no fetched source contains are recorded as `Serbest metin` rows, quoted verbatim, with the decision they settled. **Coverage gate**: every entry in `evidence.figma[]`, `confluence[]`, `jira[]`, `swagger[]`, `repo[]`, `standards[]`, `firebase[]`, `documents[]`, `outside[]`, `freeText[]` and every entry in `evidence.fetchErrors[]` must appear as a row, and every row must map to an evidence entry. A source that shaped the document but is missing from References fails the dispatch gate; so does an invented row with no evidence behind it.
58
58
  35. **Stack-optional render.** Platform and repo selection are optional. When `state.analysisSpec.platforms[]` is empty, the run still completes: the analysis layers that do not need a target repository render in full - Part A and Part B in the corporate profile, Sections 1-12 and 16-17 in the global profile - and only the development layer is dropped (corporate Part C; global Sections 13, 14, 15) along with the Pass B projection, since there are no conventions to project onto. A Section 20 row records that the development analysis awaits a repo selection. **The channel split survives the missing repo.** Channels are derived from the evidence instead of repo stack tags (`intake.md` Step 3 carries the signal table) and one document is emitted per derived channel - `mobile`, `web`, or both. A phone screen and a browser screen carry different requirements before anyone has picked a repository; the split (Locked 9) exists to carry that difference and only its *projection* half needs conventions. `mobile` stays one channel rather than iOS plus Android, since without conventions nothing tells the two apart. Evidence with no interface at all yields a single channel-agnostic `<feature>.md`. Files land under `~/Desktop/multiAgentAnalysis/<feature-name>/`, named `<feature>-<channel>.md` (or `<feature>.md` for the channel-agnostic case): the repo-relative `analysis/` path has nothing to be relative to without a repo, and the current working directory is never used, since for a repo-less run it is arbitrary. Desktop rather than a hidden directory because the document is a deliverable somebody is meant to open and hand over, and `multiAgentAnalysis` rather than a bare `Analysis` because a generic word collides with whatever else is on a desktop while the producer name groups every run this command ever writes. The Phase 3.5 picker shows the resolved path and takes an override through its Other input. A requirements document is useful before anyone has decided which repository will hold the code, and refusing to produce one until that decision exists inverts the order the work actually happens in.
59
+ 36. **The document is reviewed before it is published.** An analysis run used to go from draft straight to dispatch behind a deterministic validator, so nothing read what it was about to publish: one run put a channel it never searched for, an open question about a frame it never opened, and twenty-three unowned `EKLENECEK` markers onto a live page. Every one is what a reader catches on the first pass. Phase 3.2 runs `phases/phase-4-review.md` Step 0 (strict validator, the host's three-reviewer set, triage) on the draft before the destination is chosen: a finding is cheap while nothing is written. Reviewers are subagents holding `analysis/review.md`, never the context that wrote the document, which cannot notice a search it never thought to run. A blocking finding returns to Phase 2b with dispatch closed and never becomes an open question, since "the document is wrong" is not something to ask the reader; capped at two returns. Phase 3.3 sorts every remaining gap into searched-and-closed, asked-and-answered, or `AS-NN`; an unstamped gap fails the dispatch gate. Autopilot runs both; only the asking degrades, into rows stamped `autopilot: could not ask`.
@@ -42,6 +42,43 @@
42
42
  ```
43
43
  User can inspect drafts before choosing output destinations in Phase 3.5.
44
44
 
45
+ ### Phase 3.2 - Draft review (BLOCKING, runs in every mode)
46
+
47
+ Locked 36 carries the rule. Operationally:
48
+
49
+ 1. Run `phases/phase-4-review.md` Step 0, the analysis-mode branch. It already defines
50
+ the strict validator, the host's three-reviewer set with its model routing, and the
51
+ triage. Do not restate it here; a second definition is the one that rots.
52
+ 2. Dispatch reviewers as subagents, each given the draft path, the state JSON and
53
+ `analysis/review.md`. Never review from this context: it cannot notice a search it
54
+ never thought to run.
55
+ 3. Blocking -> back to Phase 2b, dispatch closed, never an open question. Advisory ->
56
+ Phase 3.3. Cap at two returns, then stop and surface what blocks.
57
+
58
+ Autopilot included. State: `phase = "reviewing_draft"`.
59
+
60
+ ### Phase 3.3 - Close the gaps (BLOCKING)
61
+
62
+ Phase 3.2 produced the list. Sort every entry and act:
63
+
64
+ | Bucket | Meaning | Action |
65
+ |---|---|---|
66
+ | A. Not searched | The evidence is reachable and this run did not look | Run the search; it closes, and never becomes a question |
67
+ | B. The user knows | Channel scope, which service backs a screen, what is in scope | Ask, `AskUserQuestion`, at most 4 per call |
68
+ | C. External | Final copy, a legal basis, a contract nobody has written | Record as `AS-NN` in Section 20 |
69
+
70
+ **Bucket A must be empty before Phase 3.5.** A gap reaches C only with a stamp:
71
+ `searched, not found` naming what was searched, or `asked, external`. Unstamped
72
+ `EKLENECEK` fails the dispatch gate. A is where most open questions came from in
73
+ practice, and none were questions - they were searches nobody ran.
74
+
75
+ B is asked in every interactive mode; autopilot moves B to C stamped `autopilot: could
76
+ not ask`, so an unanswerable gap reads differently from an unasked one. A stays required
77
+ under autopilot: a search needs no human.
78
+
79
+ Re-render affected sections, then report gaps closed by search, by asking, and entered.
80
+ State: `phase = "reviewing_gaps"`.
81
+
45
82
  ### Phase 3.5 - Output destination picker
46
83
 
47
84
  AskUserQuestion (multiSelect=true), at least one selection required. `Local file` pre-selected per Locked decision 5.
@@ -169,15 +206,13 @@ Per the `analysis-output-confluence-on-request` memory, Confluence post is NEVER
169
206
  2. Use parent page URL from user input. No default parent is hardcoded here; the user picks one at the prompt (LRU recents come from `prefs.projects[<project>].confluenceUrls`).
170
207
 
171
208
  **Corporate profile exception.** When `state.analysisSpec.profile == "corporate"` and the bindings are configured, the destination is already settled and the prompt is skipped: the space comes from `prefs.global.analysisProfile.corporate.confluenceSpaceKey`, the parent from `prefs.global.analysisProfile.corporate.confluenceParentPageId`, and the page title is built from `prefs.global.analysisProfile.corporate.titleFormat` with `prefs.global.analysisProfile.corporate.titlePrefix` filling its `{prefix}` placeholder. A corporate analysis always lands in the same tree, so asking each time is a question whose answer never changes. Any of the four missing falls back to the prompt above rather than guessing, and a title that would collide with an existing page becomes an update (PUT with version bump), never a second page.
172
- 3. **Publish with `md2confluence-v3.py`, never a hand-rolled conversion + curl.** The
173
- script owns the three steps that are easy to get wrong and that a hand-rolled path
174
- has already dropped in practice: the storage-XML conversion (the table in
175
- `$HOME/.claude/multi-agent-refs/channels/confluence.md`), the multipart upload of
176
- every PNG in `--attachments-dir`, and the `![](file.png)` to
177
- `<ac:image><ri:attachment ri:filename="file.png"/></ac:image>` injection that makes
178
- a frame gallery render as pictures instead of as filenames (Locked 18). It also
179
- warns when a body references an image no attachment matched, which is the signal
180
- that a screenshot never downloaded.
209
+ 3. **Publish with `md2confluence-v3.py`, never a hand-rolled conversion + curl.** It
210
+ owns the three steps a hand-rolled path has already dropped: storage-XML conversion
211
+ (`channels/confluence.md`), the multipart upload of every PNG in `--attachments-dir`,
212
+ and the `![](file.png)` to `<ac:image><ri:attachment/>` injection that makes a frame
213
+ gallery render as pictures rather than filenames (Locked 18). It also warns when a
214
+ body references an image no attachment matched, which is how a missing screenshot
215
+ announces itself.
181
216
 
182
217
  ```bash
183
218
  python3 "$HOME/.claude/lib/md2confluence-v3.py" create \
@@ -31,9 +31,25 @@ node "$HOME/.claude/scripts/build-references.mjs" "<state>" --check "<doc>"
31
31
 
32
32
  **No state JSON, no references gate.** Say so in the report rather than passing silently: the coverage claim ("every source the run read is listed") is exactly the claim nobody can verify from the document alone, and marking it unchecked is the honest output.
33
33
 
34
+ ## Phase 1.5 - What did the run skip?
35
+
36
+ Ask this before rubric quality. A document can be well-formed and still be wrong
37
+ because the run never looked. Every row here is a finding class observed in a real run.
38
+
39
+ | Skip class | Evidence it happened |
40
+ |---|---|
41
+ | A declared-missing input nothing searched for | `evidence.fetchErrors[]` has no entry for it and the document cites no scan |
42
+ | An open question about evidence never read | a Section 20 row names a node, page or file with no extracted content behind it |
43
+ | A gap with no owner | `EKLENECEK` with no `AS-NN`, or an `AS-NN` naming nobody |
44
+ | A scope call made without asking | the document narrows what the request asked for and no picker answer records it |
45
+
46
+ A skip is blocking, not advisory: the fix is a search or a question, and both are
47
+ cheaper before publication than a correction after it. Report each with what should
48
+ have been searched, so Phase 3.3 can put it in the right bucket.
49
+
34
50
  ## Phase 2 - Rubric
35
51
 
36
- Parallel model review, same shape as `/multi-agent:review`: 2 models on Claude Code, 3 on Copilot CLI, then triage. Each reviewer answers the rubric below against the resolved profile and returns findings with `Locked <n>` or a rubric id, a quote from the document, and what a reader cannot do because of it.
52
+ Parallel model review, same shape as `/multi-agent:review`: 3 models on Claude Code, 3 on Copilot CLI, then triage. Each reviewer answers the rubric below against the resolved profile and returns findings with `Locked <n>` or a rubric id, a quote from the document, and what a reader cannot do because of it.
37
53
 
38
54
  **A. Buildability** - could an implementer start from this alone?
39
55
 
@@ -283,11 +283,22 @@ One table per service. Contracts come from the live specification, never from pr
283
283
  | **Method** | GET / POST / PUT / DELETE |
284
284
  | **Description** | <what it does> |
285
285
  | **Success and error codes** | **200** <meaning> **401** <meaning> **403** <meaning> |
286
- | **Request** | <example body, fenced> |
287
- | **Response** | <example body, fenced> |
286
+ | **Request** | Asagidaki govde / Govde yok / EKLENECEK (AS-NN) |
287
+ | **Response** | Asagidaki govde / Govde yok / EKLENECEK (AS-NN) |
288
288
  ```
289
289
 
290
- A session-bound service states its token requirement in the `Request` cell. A service that does not exist yet is named with an explicit note rather than a guessed path.
290
+ **Payloads live below the table, never inside a cell.** Each is a fenced ```json block,
291
+ beautified, preceded by a label line: `Request:`, `Response:`, or a status code
292
+ (`200:`). Field notes get their own table (`Alan | Tip | Zorunlu | Aciklama`); a note
293
+ pasted after inline JSON in the same cell is what made one document render `Request`
294
+ and `Response` in two different formats in the same table. A `{` or `}` inside a
295
+ service cell is a validator ERROR.
296
+
297
+ A session-bound service states its token requirement as one sentence under the field
298
+ table, not inside the request payload. When only the name is unknown the table still
299
+ renders; when the whole contract is unknown, write one line instead of six `EKLENECEK`
300
+ cells: `Bu servisin sozlesmesi tanimli degil (AS-NN).` Render the table only once
301
+ `Name` and `Path` are both known.
291
302
 
292
303
  ### 5.(N+5) Servis - Fonksiyonel Gereksinim Eşleştirmesi
293
304
 
@@ -80,7 +80,7 @@ Phase 3: Dev
80
80
  Context: Phase 2 plan + task dependencies
81
81
 
82
82
  Phase 4: Review (CLI-aware parallel + triage)
83
- Claude Code (2 parallel):
83
+ Claude Code (3 parallel):
84
84
  |-- Reviewer (opus) -> security + architecture
85
85
  +-- Reviewer (sonnet) -> quality + correctness + edge cases
86
86
  Copilot CLI (3 parallel):
@@ -1,6 +1,6 @@
1
1
  ### Phase 4: Review (deterministic gates + parallel + triage)
2
2
 
3
- > **TLDR** - Three-stage review. Stage 1: deterministic gates (build + lint + test + secret scan) that MUST pass. Stage 2: AI models in parallel - reviewer set is **CLI-aware**: Claude Code dispatches 2 reviewers (Fable + Sonnet); Copilot CLI dispatches 3 reviewers (GPT-5.4 + Opus + Sonnet - Fable 5 is not offered on Copilot CLI). Stage 3: Fable triage (Opus on Copilot CLI) - evaluates raw findings, filters false-positives/out-of-scope, keeps only actionable items. Only triage-accepted blocking items loop back to Phase 3.
3
+ > **TLDR** - Three-stage review. Stage 1: deterministic gates (build + lint + test + secret scan) that MUST pass. Stage 2: AI models in parallel - every host dispatches 3 reviewers; only the second slot is **CLI-aware**: Claude Code dispatches Fable + Opus + Sonnet; Copilot CLI dispatches 3 reviewers (GPT-5.4 + Opus + Sonnet - Fable 5 is not offered on Copilot CLI). Stage 3: Fable triage (Opus on Copilot CLI) - evaluates raw findings, filters false-positives/out-of-scope, keeps only actionable items. Only triage-accepted blocking items loop back to Phase 3.
4
4
 
5
5
  <!-- progress-contract: applied -->
6
6
  Progress emission per `$HOME/.claude/multi-agent-refs/progress-contract.md` - lines for each gate, each reviewer dispatch + finish, triage start, triage verdict, fix dispatch.
@@ -264,18 +264,18 @@ Phase 4 sends the same diff to every reviewer and then to triage, so the diff is
264
264
 
265
265
  #### Step 2 - Parallel AI Review (CLI-aware reviewer set)
266
266
 
267
- Launch Agent instances **in parallel** using the shared `code-reviewer` subagent definition (`~/.claude/agents/code-reviewer.md`). The reviewer set is determined by the host CLI - GPT-5.4 is only available on Copilot CLI, so Claude Code skips that reviewer and runs a 2-model parallel review; Copilot CLI runs all three.
267
+ Launch Agent instances **in parallel** using the shared `code-reviewer` subagent definition (`~/.claude/agents/code-reviewer.md`). Every host runs three reviewers; only the second slot differs, because GPT-5.4 exists on Copilot and Codex but not on Claude Code, where Opus fills it. Three reviewers on Claude Code cost more than two, and the cost buys the thing a second opinion cannot: a finding two independent readers both miss is what triage has no chance to catch.
268
268
 
269
269
  **Scope from Step 1.77.** `$REVIEW_SCOPE == "single"` → dispatch **Reviewer 1 only**, and skip Step 2.5 + 3.6 (both no-ops with one reviewer). `"full"` (default + fail-safe) → the whole set below. Either way record the count in `consensus.reviewerCount`.
270
270
 
271
271
  | Reviewer | subagent_type | Claude Code | Copilot CLI | Codex CLI | Focus | Skills Referenced |
272
272
  | ---------- | --------------- | --- | --- | --- | --- | --- |
273
273
  | Reviewer 1 | `code-reviewer` | `claude-fable-5` | `claude-opus-5` | `gpt-5.6` @ `xhigh` | Deep security + architecture | `api-security-best-practices`, `architecture` |
274
- | Reviewer 2 | `code-reviewer` | (not dispatched) | `gpt-5.4` | `gpt-5.4` @ `high` | Edge cases, different perspective | cross-model diversity |
274
+ | Reviewer 2 | `code-reviewer` | `claude-opus-5` | `gpt-5.4` | `gpt-5.4` @ `high` | Edge cases, different perspective | cross-model diversity |
275
275
  | Reviewer 3 | `code-reviewer` | `claude-sonnet-5` | `claude-sonnet-5` | `gpt-5.6` @ `medium` | Quality + correctness + naming | `ai-backend-toolkit:clean-code`, stack-specific skill |
276
276
  | Triage | triage persona | `claude-fable-5` | `claude-opus-5` | `gpt-5.6` @ `max` | Filter false positives + out-of-scope | - |
277
277
 
278
- Reviewer count per host: **Claude Code 2, Copilot CLI 3, Codex CLI 3**.
278
+ Reviewer count per host: **Claude Code 3, Copilot CLI 3, Codex CLI 3**.
279
279
 
280
280
  #### Codex CLI - two constraints that fail silently
281
281
 
@@ -302,11 +302,11 @@ in the triage note when all three agree on a borderline finding.
302
302
 
303
303
  Each reviewer inherits the `code-reviewer` agent's focus areas (Security, Architecture, Quality, Performance) and output contract. The orchestrator overrides only the model and the stack-specific skill per-reviewer - no prompt duplication.
304
304
 
305
- **Model override wiring:** `code-reviewer.md` declares `preferredModel: fable`, so Reviewer 1 uses the persona default (Fable 5). Reviewer 2 (Copilot-only, `gpt-5.4`) and Reviewer 3 (`claude-sonnet-5`) set `PHASE_MODEL_OVERRIDE=<model>` before dispatch - the orchestrator exports `CLAUDE_CODE_SUBAGENT_MODEL` on Claude Code, or passes `--model` on Copilot CLI. Full precedence rule: `skills/shared/core/multi-agent/SKILL.md#agent-dispatch--per-persona-model-routing-v610`. Fable dispatches are subject to the fallback contract (`$HOME/.claude/multi-agent-refs/features/model-fallback.md`): dispatch-error retry walks `fable -> opus -> sonnet` and budget-ceiling downgrade.
305
+ **Model override wiring:** `code-reviewer.md` declares `preferredModel: fable`, so Reviewer 1 uses the persona default (Fable 5). Reviewer 2 (`claude-opus-5` on Claude Code, `gpt-5.4` elsewhere) and Reviewer 3 (`claude-sonnet-5`) set `PHASE_MODEL_OVERRIDE=<model>` before dispatch - the orchestrator exports `CLAUDE_CODE_SUBAGENT_MODEL` on Claude Code, or passes `--model` on Copilot CLI. Full precedence rule: `skills/shared/core/multi-agent/SKILL.md#agent-dispatch--per-persona-model-routing-v610`. Fable dispatches are subject to the fallback contract (`$HOME/.claude/multi-agent-refs/features/model-fallback.md`): dispatch-error retry walks `fable -> opus -> sonnet` and budget-ceiling downgrade.
306
306
 
307
- **Stack-specific skills loaded per reviewer** (from Phase 1 `detectedStack`). On Claude Code, Reviewer 2 (GPT-5.4) is not dispatched - its skill column is ignored. On Copilot CLI all three columns are used.
307
+ **Stack-specific skills loaded per reviewer** (from Phase 1 `detectedStack`). All three columns are used on every host; Reviewer 2 reads them as Opus on Claude Code and as GPT-5.4 elsewhere.
308
308
 
309
- | Stack | Reviewer 1 (Fable / Opus on Copilot) | Reviewer 2 (GPT-5.4 - Copilot CLI only) | Reviewer 3 (Sonnet) |
309
+ | Stack | Reviewer 1 (Fable / Opus on Copilot) | Reviewer 2 (Opus on Claude Code, GPT-5.4 elsewhere) | Reviewer 3 (Sonnet) |
310
310
  |-------|-------------------|-----------------------------------------|---------------------|
311
311
  | iOS/Swift | `ai-ios-toolkit:ios-security`, `ai-ios-toolkit:swiftui-performance`, `ai-ios-toolkit:hig-patterns` | `ai-ios-toolkit:swift-concurrency`, `ai-ios-toolkit:ios-accessibility` | `ai-ios-toolkit:swiftui-pro`, `ai-ios-toolkit:swift-testing` |
312
312
  | Android/Kotlin | `ai-android-toolkit:android-security`, `ai-android-toolkit:android-performance` | `ai-android-toolkit:compose-testing`, `ai-android-toolkit:android-architecture` | `ai-android-toolkit:compose-components`, `ai-android-toolkit:kotlin-coroutines-expert` |
@@ -557,7 +557,7 @@ emit() { # $1=event $2=model $3=duration $4=tokens_in $5=tokens_out
557
557
  }
558
558
  emit review.reviewer_call fable "$R1_DURATION" "$R1_IN" "$R1_OUT" # opus on Copilot CLI
559
559
  emit review.reviewer_call sonnet "$SONNET_DURATION" "$SONNET_IN" "$SONNET_OUT"
560
- # Reviewer 2 is GPT-5.4 and exists only on Copilot CLI:
560
+ # Reviewer 2 is Opus on Claude Code and GPT-5.4 elsewhere:
561
561
  [ "${CLI_HOST:-claude}" = "copilot" ] && \
562
562
  emit review.reviewer_call gpt-5.4 "$GPT_DURATION" "$GPT_IN" "$GPT_OUT"
563
563
  emit review.triage_call fable "$TRIAGE_DURATION" "$TRIAGE_IN" "$TRIAGE_OUT"