open-research-protocol 0.4.21 → 0.4.23

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.
@@ -48,8 +48,12 @@ If the agent only remembers one ORP loop, it should be this:
48
48
  ```bash
49
49
  orp frontier state --json
50
50
  ```
51
- 5. do the next honest move
52
- 6. checkpoint it honestly
51
+ 5. if the work feels confusing or too large, break it down before moving
52
+ ```bash
53
+ orp mode breakdown granular-breakdown --json
54
+ ```
55
+ 6. do the next honest move
56
+ 7. checkpoint it honestly
53
57
  ```bash
54
58
  orp checkpoint create -m "checkpoint note" --json
55
59
  ```
@@ -60,6 +64,7 @@ That is the ORP rhythm in one line:
60
64
  - inspect repo safety
61
65
  - resolve access
62
66
  - inspect context
67
+ - break down complexity when comprehension would help
63
68
  - do the work
64
69
  - checkpoint it honestly
65
70
 
package/README.md CHANGED
@@ -122,9 +122,11 @@ orp report summary
122
122
  orp frontier state
123
123
  orp schedule list
124
124
  orp mode nudge sleek-minimal-progressive
125
+ orp mode breakdown granular-breakdown
126
+ orp mode nudge granular-breakdown
125
127
  ```
126
128
 
127
- That sequence covers discovery, agent-guide alignment, workspace recovery, agenda refresh, secret resolution, governance, artifacts, planning, automation, and perspective-shift support.
129
+ That sequence covers discovery, agent-guide alignment, workspace recovery, agenda refresh, secret resolution, governance, artifacts, planning, automation, perspective-shift support, and intentional breakdown when the work needs more granular comprehension. Use `mode breakdown` for the full broad-to-atomic ladder; use `mode nudge` when you only need a short reminder card.
128
130
 
129
131
  The shorter rule is:
130
132
 
@@ -403,6 +405,9 @@ orp about --json
403
405
  orp mode list --json
404
406
  orp mode show sleek-minimal-progressive --json
405
407
  orp mode nudge sleek-minimal-progressive --json
408
+ orp mode show granular-breakdown --json
409
+ orp mode breakdown granular-breakdown --json
410
+ orp mode nudge granular-breakdown --json
406
411
  orp update --json
407
412
  orp maintenance status --json
408
413
  ```
@@ -427,6 +432,7 @@ orp workspace create mac-main --machine-label "Mac Studio"
427
432
  orp workspace list
428
433
  orp workspace tabs main
429
434
  orp workspace add-tab main --path /absolute/path/to/project --remote-url git@github.com:org/project.git --bootstrap-command "npm install" --resume-command "codex resume <id>"
435
+ orp workspace add-tab main --path /absolute/path/to/project --title "second active thread" --resume-tool claude --resume-session-id <id> --append
430
436
  orp workspace remove-tab main --path /absolute/path/to/project
431
437
  orp workspace sync main
432
438
  orp secrets list --json
package/cli/orp.py CHANGED
@@ -286,7 +286,7 @@ YOUTUBE_ANDROID_CLIENT_VERSION = "20.10.38"
286
286
  YOUTUBE_ANDROID_USER_AGENT = (
287
287
  f"com.google.android.youtube/{YOUTUBE_ANDROID_CLIENT_VERSION} (Linux; U; Android 14)"
288
288
  )
289
- AGENT_MODE_REGISTRY_VERSION = "1.0.0"
289
+ AGENT_MODE_REGISTRY_VERSION = "1.2.0"
290
290
  AGENT_MODES: list[dict[str, Any]] = [
291
291
  {
292
292
  "id": "sleek-minimal-progressive",
@@ -597,6 +597,175 @@ AGENT_MODES: list[dict[str, Any]] = [
597
597
  },
598
598
  ],
599
599
  },
600
+ {
601
+ "id": "granular-breakdown",
602
+ "aliases": ["breakdown", "breakdown-mode", "stepwise-breakdown", "gb"],
603
+ "label": "Granular Breakdown",
604
+ "summary": "An optional comprehension overlay for breaking complex work into smaller pieces, ordered dependencies, and concrete next moves.",
605
+ "operator_reminder": "Use this when the work is hard to hold in your head, when the user sounds confused, or when a plan needs more intentional granularity before execution.",
606
+ "activation_phrase": "Break it down until it can move.",
607
+ "invocation_style": "Optional but core-loop friendly. Call it whenever comprehension, onboarding, or safe execution would improve with smaller steps.",
608
+ "when_to_use": [
609
+ "When a project, error, or plan feels too large to reason about at once.",
610
+ "When the user asks what something means or seems unsure about the workflow.",
611
+ "When a change has several dependencies that should be sequenced before implementation.",
612
+ "When a handoff needs to be understandable to a future agent or collaborator.",
613
+ ],
614
+ "perspective_shifts": [
615
+ "Turn the blob into named parts before debating solutions.",
616
+ "Separate current state, desired state, blockers, and next action.",
617
+ "Order the parts by dependency, risk, and user comprehension.",
618
+ "Prefer a small verified loop over a large impressive explanation.",
619
+ ],
620
+ "principles": [
621
+ "Granularity is a service to comprehension, not a performance of detail.",
622
+ "Every breakdown should create a next move, not just a longer list.",
623
+ "Name assumptions before building on top of them.",
624
+ "Compress again after decomposing so the user gets clarity, not clutter.",
625
+ ],
626
+ "ritual": [
627
+ "Name the whole problem in one sentence.",
628
+ "Split it into current state, desired state, and missing bridge.",
629
+ "List the smallest meaningful steps in dependency order.",
630
+ "Identify the next test or confirmation that proves movement.",
631
+ ],
632
+ "questions": [
633
+ "What are the actual pieces here?",
634
+ "Which piece needs to be understood first for the rest to make sense?",
635
+ "What is blocking action versus only blocking confidence?",
636
+ "What is the smallest safe step that would reduce ambiguity?",
637
+ "After the breakdown, what can we compress back into one clear sentence?",
638
+ ],
639
+ "anti_patterns": [
640
+ "Making a breakdown so exhaustive that it becomes another wall of confusion.",
641
+ "Skipping the dependency order and calling a pile of bullets a plan.",
642
+ "Explaining implementation details before naming the user's actual question.",
643
+ "Using granularity to delay the next concrete move.",
644
+ ],
645
+ "micro_loop": [
646
+ "Name the whole confusing thing in one plain sentence.",
647
+ "Split it into current state, desired state, missing bridge, and dependency order.",
648
+ "Choose one small verification that reduces ambiguity, then compress the result back down.",
649
+ ],
650
+ "breakdown_sequence": [
651
+ {
652
+ "level_id": "L0_whole_frame",
653
+ "title": "Whole Frame",
654
+ "purpose": "Start broad enough that the user and future agent know what object is being decomposed.",
655
+ "prompt": "What is the broad claim, request, or confusion in one plain sentence?",
656
+ "output": "One sentence that names the top-level object without trying to solve it yet.",
657
+ "completion_check": "A future reader can tell what cathedral we are talking about before seeing any stones.",
658
+ },
659
+ {
660
+ "level_id": "L1_boundary",
661
+ "title": "Boundary",
662
+ "purpose": "Separate proof, implementation, and discovery so the breakdown does not overclaim.",
663
+ "prompt": "What is the exact desired state, and what is explicitly not being claimed yet?",
664
+ "output": "A narrowed north star plus falsifier, exception, or overclaim boundaries.",
665
+ "completion_check": "The target is smaller than the dream and honest about what remains unproved or undone.",
666
+ },
667
+ {
668
+ "level_id": "L2_major_lanes",
669
+ "title": "Major Lanes",
670
+ "purpose": "Break the top-level target into a small number of non-overlapping lanes.",
671
+ "prompt": "Which 3-7 lanes or phases cover the work without overlapping too much?",
672
+ "output": "Named lanes such as counting language, compatibility language, graph reduction, matching bounds, margin lift, finite closure.",
673
+ "completion_check": "Every important concern has a lane, and no lane is pretending to be the whole proof or project.",
674
+ },
675
+ {
676
+ "level_id": "L3_subclaims",
677
+ "title": "Subclaims",
678
+ "purpose": "Turn each lane into named obligations that can be owned, proved, tested, or handed off.",
679
+ "prompt": "What smaller claims, tasks, or lemmas make each lane true?",
680
+ "output": "Task IDs with statements, hypotheses, dependencies, and proof or work type.",
681
+ "completion_check": "Each subclaim is small enough to discuss independently and large enough to matter.",
682
+ },
683
+ {
684
+ "level_id": "L4_atomic_obligations",
685
+ "title": "Atomic Obligations",
686
+ "purpose": "Descend until each item is concrete enough to verify independently.",
687
+ "prompt": "Can each subclaim be split until it is either a definition check, counting identity, graph identity, finite check, data check, API check, or implementation step?",
688
+ "output": "Sub-sub-lemmas or atomic tasks with a falsifier boundary for each.",
689
+ "completion_check": "Every atomic item can be proved, computed, tested, or falsified without needing the whole plan in memory.",
690
+ },
691
+ {
692
+ "level_id": "L5_dependency_ladder",
693
+ "title": "Dependency Ladder",
694
+ "purpose": "Order the atomic pieces by prerequisite, risk, and comprehension value.",
695
+ "prompt": "What has to be true before the next rung can safely happen?",
696
+ "output": "A dependency-ordered ladder with blockers, ready steps, and downstream unlocks.",
697
+ "completion_check": "The first few moves are ordered for real dependency reasons, not just narrative convenience.",
698
+ },
699
+ {
700
+ "level_id": "L6_active_target",
701
+ "title": "Active Target",
702
+ "purpose": "Choose the first actionable rung and the current target theorem, task, or patch.",
703
+ "prompt": "What is the first actionable rung, what is the current target rung, and why is it the best next place to work?",
704
+ "output": "Current packet, first actionable step, target step, and why this is the next best move.",
705
+ "completion_check": "The next move is obvious enough that an agent can start without re-deriving the full breakdown.",
706
+ },
707
+ {
708
+ "level_id": "L7_durable_checklist",
709
+ "title": "Durable Checklist",
710
+ "purpose": "Promote important breakdowns out of chat memory and into a referenceable artifact.",
711
+ "prompt": "Where does this breakdown live so future agents can update it without depending on chat memory?",
712
+ "output": "A markdown checklist plus an optional machine-readable JSON checklist with statuses, dependencies, source artifacts, and falsifier boundaries.",
713
+ "completion_check": "The repo has a checklist that can move steps from todo to next to done without losing context.",
714
+ },
715
+ {
716
+ "level_id": "L8_compress_and_continue",
717
+ "title": "Compress And Continue",
718
+ "purpose": "End simpler than we began by turning the full ladder back into the next honest move.",
719
+ "prompt": "After decomposing the work, what is the shortest useful summary and the next verification?",
720
+ "output": "A compact summary, the active target, and one verification command, artifact, or proof check.",
721
+ "completion_check": "The user gets clarity and momentum, not just a larger pile of scaffolding.",
722
+ },
723
+ ],
724
+ "durable_artifact_rule": "If the breakdown becomes operationally important, write it to a durable checklist artifact with step IDs, dependencies, statuses, source artifacts, falsifier boundaries, and the first active target.",
725
+ "breakdown_output_contract": [
726
+ "Broad Frame",
727
+ "Boundary",
728
+ "Major Lanes",
729
+ "Subclaims",
730
+ "Atomic Obligations",
731
+ "Dependency Ladder",
732
+ "Active Target",
733
+ "Durable Checklist",
734
+ "Next Verification",
735
+ ],
736
+ "nudge_cards": [
737
+ {
738
+ "title": "Name The Blob",
739
+ "prompt": "Write the whole confusing thing as one sentence, then split it into three named parts.",
740
+ "twist": "If a part cannot be named plainly, it is not understood yet.",
741
+ "release": "A named part is easier to move than an unnamed worry.",
742
+ },
743
+ {
744
+ "title": "Current / Desired / Bridge",
745
+ "prompt": "Separate current state, desired state, and the missing bridge before proposing the fix.",
746
+ "twist": "Do not let the desired state masquerade as the plan.",
747
+ "release": "The bridge is where the real work usually lives.",
748
+ },
749
+ {
750
+ "title": "Dependency Ladder",
751
+ "prompt": "Order the next steps by what must be true before the following step can safely happen.",
752
+ "twist": "Put user comprehension on the ladder, not only code dependencies.",
753
+ "release": "A good ladder makes the climb feel obvious.",
754
+ },
755
+ {
756
+ "title": "Tiny Verified Loop",
757
+ "prompt": "Choose one small action and one verification that would reduce ambiguity within the next pass.",
758
+ "twist": "The step should be small enough to finish, but real enough to matter.",
759
+ "release": "Confidence compounds through verified movement.",
760
+ },
761
+ {
762
+ "title": "Explain Then Compress",
763
+ "prompt": "Break the topic down in detail, then compress the result into the shortest useful summary.",
764
+ "twist": "Do not leave the user with the scaffolding if the building is now clear.",
765
+ "release": "The best breakdown ends simpler than it began.",
766
+ },
767
+ ],
768
+ },
600
769
  ]
601
770
 
602
771
 
@@ -641,6 +810,7 @@ def _agent_mode_public_payload(mode: dict[str, Any]) -> dict[str, Any]:
641
810
  "questions": [str(row).strip() for row in mode.get("questions", []) if str(row).strip()],
642
811
  "anti_patterns": [str(row).strip() for row in mode.get("anti_patterns", []) if str(row).strip()],
643
812
  "nudge_card_count": len(mode.get("nudge_cards", [])) if isinstance(mode.get("nudge_cards"), list) else 0,
813
+ "breakdown_sequence_count": len(mode.get("breakdown_sequence", [])) if isinstance(mode.get("breakdown_sequence"), list) else 0,
644
814
  }
645
815
 
646
816
 
@@ -659,6 +829,13 @@ def _agent_mode_nudge(mode: dict[str, Any], *, seed: str = "") -> dict[str, Any]
659
829
  digest = hashlib.sha256(f"{mode['id']}::{effective_seed}".encode("utf-8")).hexdigest()
660
830
  index = int(digest[:8], 16) % len(cards)
661
831
  card = cards[index]
832
+ micro_loop = mode.get("micro_loop", [])
833
+ if not isinstance(micro_loop, list) or not micro_loop:
834
+ micro_loop = [
835
+ "Choose the right lens first: deeper, higher, wider, or rotated.",
836
+ "Make one pass sleeker by removing friction and generic weight.",
837
+ "Make one pass playful or progressive by trying one meaningful shift in angle.",
838
+ ]
662
839
  return {
663
840
  "mode": _agent_mode_public_payload(mode),
664
841
  "seed": effective_seed,
@@ -669,11 +846,57 @@ def _agent_mode_nudge(mode: dict[str, Any], *, seed: str = "") -> dict[str, Any]
669
846
  "twist": str(card.get("twist", "")).strip(),
670
847
  "release": str(card.get("release", "")).strip(),
671
848
  },
672
- "micro_loop": [
673
- "Choose the right lens first: deeper, higher, wider, or rotated.",
674
- "Make one pass sleeker by removing friction and generic weight.",
675
- "Make one pass playful or progressive by trying one meaningful shift in angle.",
676
- ],
849
+ "micro_loop": [str(row).strip() for row in micro_loop if str(row).strip()],
850
+ }
851
+
852
+
853
+ def _agent_mode_breakdown(mode: dict[str, Any], *, topic: str = "") -> dict[str, Any]:
854
+ sequence = mode.get("breakdown_sequence", [])
855
+ if not isinstance(sequence, list) or not sequence:
856
+ raise RuntimeError("This mode does not define a breakdown sequence.")
857
+
858
+ normalized_sequence: list[dict[str, str]] = []
859
+ for row in sequence:
860
+ if not isinstance(row, dict):
861
+ continue
862
+ normalized_sequence.append(
863
+ {
864
+ "level_id": str(row.get("level_id", row.get("level", ""))).strip(),
865
+ "title": str(row.get("title", row.get("label", ""))).strip(),
866
+ "purpose": str(row.get("purpose", "")).strip(),
867
+ "prompt": str(row.get("prompt", row.get("question", ""))).strip(),
868
+ "output": str(row.get("output", "")).strip(),
869
+ "completion_check": str(row.get("completion_check", "")).strip(),
870
+ }
871
+ )
872
+
873
+ artifact_rule = str(mode.get("durable_artifact_rule", mode.get("artifact_rule", ""))).strip()
874
+ output_contract = [
875
+ str(row).strip()
876
+ for row in mode.get(
877
+ "breakdown_output_contract",
878
+ [
879
+ "Broad Frame",
880
+ "Boundary",
881
+ "Major Lanes",
882
+ "Subclaims",
883
+ "Atomic Obligations",
884
+ "Dependency Ladder",
885
+ "Active Target",
886
+ "Durable Checklist",
887
+ "Next Verification",
888
+ ],
889
+ )
890
+ if str(row).strip()
891
+ ]
892
+
893
+ return {
894
+ "mode": _agent_mode_public_payload(mode),
895
+ "topic": str(topic or "").strip(),
896
+ "sequence": normalized_sequence,
897
+ "durable_artifact_rule": artifact_rule,
898
+ "artifact_rule": artifact_rule,
899
+ "output_contract": output_contract,
677
900
  }
678
901
 
679
902
 
@@ -3443,6 +3666,9 @@ def _hosted_api_error(
3443
3666
  payload: dict[str, Any] | None,
3444
3667
  ) -> HostedApiError:
3445
3668
  message = str((payload or {}).get("error") or (payload or {}).get("message") or f"Request failed: {status}")
3669
+ stripped_message = message.lstrip()
3670
+ if stripped_message.startswith("<!DOCTYPE html") or stripped_message.startswith("<html"):
3671
+ message = "Hosted ORP returned an HTML error page instead of JSON"
3446
3672
  suffix = f" (status={status} path={path})"
3447
3673
  hint = ""
3448
3674
  if status == 401:
@@ -3450,7 +3676,13 @@ def _hosted_api_error(
3450
3676
  elif status == 403:
3451
3677
  hint = " The hosted ORP app rejected the operation. Check permissions on the target record."
3452
3678
  elif status == 404:
3453
- hint = " The hosted record may have changed. Re-list the resource and retry."
3679
+ if message == "Hosted ORP returned an HTML error page instead of JSON":
3680
+ hint = (
3681
+ " The hosted API route may not be deployed at this base URL. "
3682
+ "Check ORP_BASE_URL or deploy the hosted ORP app."
3683
+ )
3684
+ else:
3685
+ hint = " The hosted record may have changed. Re-list the resource and retry."
3454
3686
  elif status == 409:
3455
3687
  hint = " The hosted record changed since you last fetched it. Re-open it and retry the update."
3456
3688
  return HostedApiError(f"{message}{suffix}.{hint}".replace("..", "."))
@@ -10632,6 +10864,7 @@ def _about_payload() -> dict[str, Any]:
10632
10864
  {"name": "mode_list", "path": ["mode", "list"], "json_output": True},
10633
10865
  {"name": "mode_show", "path": ["mode", "show"], "json_output": True},
10634
10866
  {"name": "mode_nudge", "path": ["mode", "nudge"], "json_output": True},
10867
+ {"name": "mode_breakdown", "path": ["mode", "breakdown"], "json_output": True},
10635
10868
  {"name": "secrets_list", "path": ["secrets", "list"], "json_output": True},
10636
10869
  {"name": "secrets_show", "path": ["secrets", "show"], "json_output": True},
10637
10870
  {"name": "secrets_add", "path": ["secrets", "add"], "json_output": True},
@@ -10734,7 +10967,7 @@ def _about_payload() -> dict[str, Any]:
10734
10967
  "Knowledge exchange is a built-in ORP ability exposed through `orp exchange repo synthesize`, producing structured exchange artifacts and transfer maps for local or remote source repositories.",
10735
10968
  "Collaboration is a built-in ORP ability exposed through `orp collaborate ...`.",
10736
10969
  "Frontier control is a built-in ORP ability exposed through `orp frontier ...`, separating the exact live point, the exact active milestone, the near structured checklist, and the farther major-version stack.",
10737
- "Agent modes are lightweight optional overlays for taste, perspective shifts, and fresh movement; `orp mode nudge sleek-minimal-progressive --json` gives agents a deterministic reminder they can call on when they want a deeper, wider, top-down, or rotated lens without changing ORP's core artifact boundaries.",
10970
+ "Agent modes are lightweight optional overlays for taste, perspective shifts, fresh movement, and intentional comprehension breakdowns; `orp mode breakdown granular-breakdown --json` gives agents a broad-to-atomic ladder for complex work, while `orp mode nudge granular-breakdown --json` gives a short reminder card.",
10738
10971
  "Project/session linking is a built-in ORP ability exposed through `orp link ...` and stored machine-locally under `.git/orp/link/`.",
10739
10972
  "Secrets are easiest to understand as saved credentials and related login metadata: humans usually run `orp secrets add ...` and paste the value at the prompt, agents usually pipe the value with `--value-stdin`, optional usernames can be stored alongside the secret when a service needs them, and local macOS Keychain caching plus hosted sync are optional layers on top.",
10740
10973
  "Connections give ORP one place to remember service accounts, public data sources, deployment targets, and which saved secret alias or named secret bindings power each integration through `orp connections providers`, `orp connections list`, `orp connections show`, `orp connections add`, `orp connections update`, `orp connections remove`, `orp connections sync`, and `orp connections pull`.",
@@ -10890,6 +11123,10 @@ def _home_payload(repo_root: Path, config_arg: str) -> dict[str, Any]:
10890
11123
  "label": "Get an optional creativity/perspective nudge",
10891
11124
  "command": "orp mode nudge sleek-minimal-progressive --json",
10892
11125
  },
11126
+ {
11127
+ "label": "Break complex work into smaller intentional steps",
11128
+ "command": "orp mode breakdown granular-breakdown --json",
11129
+ },
10893
11130
  ]
10894
11131
 
10895
11132
  quick_actions = [
@@ -10993,6 +11230,14 @@ def _home_payload(repo_root: Path, config_arg: str) -> dict[str, Any]:
10993
11230
  "label": "Get an optional creative perspective nudge for agent work",
10994
11231
  "command": "orp mode nudge sleek-minimal-progressive --json",
10995
11232
  },
11233
+ {
11234
+ "label": "Get an optional breakdown nudge when work needs more granularity",
11235
+ "command": "orp mode nudge granular-breakdown --json",
11236
+ },
11237
+ {
11238
+ "label": "Generate a broad-to-atomic breakdown ladder for complex work",
11239
+ "command": "orp mode breakdown granular-breakdown --json",
11240
+ },
10996
11241
  {
10997
11242
  "label": "List first-class hosted workspaces",
10998
11243
  "command": "orp workspaces list --json",
@@ -11356,11 +11601,14 @@ def _home_payload(repo_root: Path, config_arg: str) -> dict[str, Any]:
11356
11601
  },
11357
11602
  {
11358
11603
  "id": "modes",
11359
- "description": "Lightweight optional cognitive overlays for taste, creativity, perspective shifts, and exploratory momentum.",
11604
+ "description": "Lightweight optional cognitive overlays for taste, creativity, perspective shifts, exploratory momentum, and granular comprehension breakdowns.",
11360
11605
  "entrypoints": [
11361
11606
  "orp mode list --json",
11362
11607
  "orp mode show sleek-minimal-progressive --json",
11363
11608
  "orp mode nudge sleek-minimal-progressive --json",
11609
+ "orp mode show granular-breakdown --json",
11610
+ "orp mode nudge granular-breakdown --json",
11611
+ "orp mode breakdown granular-breakdown --json",
11364
11612
  ],
11365
11613
  },
11366
11614
  {
@@ -15024,6 +15272,7 @@ def cmd_mode_show(args: argparse.Namespace) -> int:
15024
15272
  ("mode.activation_phrase", mode_payload["activation_phrase"]),
15025
15273
  ("mode.invocation_style", mode_payload["invocation_style"]),
15026
15274
  ("mode.nudge_card_count", mode_payload["nudge_card_count"]),
15275
+ ("mode.breakdown_sequence_count", mode_payload["breakdown_sequence_count"]),
15027
15276
  ]
15028
15277
  )
15029
15278
  for key in ("when_to_use", "perspective_shifts", "principles", "ritual", "questions", "anti_patterns"):
@@ -15064,6 +15313,43 @@ def cmd_mode_nudge(args: argparse.Namespace) -> int:
15064
15313
  return 0
15065
15314
 
15066
15315
 
15316
+ def cmd_mode_breakdown(args: argparse.Namespace) -> int:
15317
+ mode = _agent_mode(getattr(args, "mode_ref", ""))
15318
+ payload = {
15319
+ "ok": True,
15320
+ **_agent_mode_breakdown(mode, topic=str(getattr(args, "topic", "") or "").strip()),
15321
+ }
15322
+ if args.json_output:
15323
+ _print_json(payload)
15324
+ return 0
15325
+
15326
+ pairs = [
15327
+ ("mode.id", payload["mode"]["id"]),
15328
+ ("mode.label", payload["mode"]["label"]),
15329
+ ("mode.activation_phrase", payload["mode"]["activation_phrase"]),
15330
+ ]
15331
+ if payload["topic"]:
15332
+ pairs.append(("breakdown.topic", payload["topic"]))
15333
+ pairs.append(("breakdown.sequence_count", len(payload["sequence"])))
15334
+ _print_pairs(pairs)
15335
+
15336
+ print("breakdown.sequence:")
15337
+ for index, row in enumerate(payload["sequence"], start=1):
15338
+ title = row.get("title", "")
15339
+ level_id = row.get("level_id", "")
15340
+ print(f"{index}. {title} [{level_id}]")
15341
+ for key in ("purpose", "prompt", "output", "completion_check"):
15342
+ value = str(row.get(key, "")).strip()
15343
+ if value:
15344
+ print(f" {key}: {value}")
15345
+ if payload["durable_artifact_rule"]:
15346
+ print(f"durable_artifact_rule={payload['durable_artifact_rule']}")
15347
+ print("output_contract:")
15348
+ for row in payload["output_contract"]:
15349
+ print(f"- {row}")
15350
+ return 0
15351
+
15352
+
15067
15353
  def cmd_update(args: argparse.Namespace) -> int:
15068
15354
  payload = _update_payload()
15069
15355
  if getattr(args, "yes", False):
@@ -22357,7 +22643,7 @@ def build_parser() -> argparse.ArgumentParser:
22357
22643
 
22358
22644
  s_mode = sub.add_parser(
22359
22645
  "mode",
22360
- help="Agent-first creative and cognitive overlay modes",
22646
+ help="Agent-first cognitive overlay modes for creativity, perspective, and breakdown",
22361
22647
  )
22362
22648
  mode_sub = s_mode.add_subparsers(dest="mode_cmd", required=True)
22363
22649
 
@@ -22372,7 +22658,7 @@ def build_parser() -> argparse.ArgumentParser:
22372
22658
 
22373
22659
  s_mode_nudge = mode_sub.add_parser(
22374
22660
  "nudge",
22375
- help="Return a deterministic creativity nudge card for one agent mode",
22661
+ help="Return a deterministic nudge card for one agent mode",
22376
22662
  )
22377
22663
  s_mode_nudge.add_argument("mode_ref", help="Mode id or alias")
22378
22664
  s_mode_nudge.add_argument(
@@ -22383,6 +22669,19 @@ def build_parser() -> argparse.ArgumentParser:
22383
22669
  add_json_flag(s_mode_nudge)
22384
22670
  s_mode_nudge.set_defaults(func=cmd_mode_nudge, json_output=False)
22385
22671
 
22672
+ s_mode_breakdown = mode_sub.add_parser(
22673
+ "breakdown",
22674
+ help="Return a broad-to-atomic breakdown ladder for one agent mode",
22675
+ )
22676
+ s_mode_breakdown.add_argument("mode_ref", help="Mode id or alias")
22677
+ s_mode_breakdown.add_argument(
22678
+ "--topic",
22679
+ default="",
22680
+ help="Optional topic label for the breakdown target",
22681
+ )
22682
+ add_json_flag(s_mode_breakdown)
22683
+ s_mode_breakdown.set_defaults(func=cmd_mode_breakdown, json_output=False)
22684
+
22386
22685
  s_update = sub.add_parser(
22387
22686
  "update",
22388
22687
  help="Check npm for a newer ORP release and print the recommended upgrade command",
@@ -16,6 +16,11 @@ read:
16
16
  exploratory reframing, run:
17
17
  - `orp mode nudge sleek-minimal-progressive --json`
18
18
  - Treat it as an optional lens for deeper, wider, top-down, or rotated perspective shifts.
19
+ - If the task feels confusing, too large to hold at once, or likely to benefit
20
+ from more intentional granularity, run:
21
+ - `orp mode breakdown granular-breakdown --json`
22
+ - Optionally follow with `orp mode nudge granular-breakdown --json` if you only need a short reminder card.
23
+ - Treat it as a broad-to-atomic ladder: whole frame, boundary, major lanes, subclaims, atomic obligations, dependency order, durable checklist, and next verification.
19
24
  - If packs matter, run `orp pack list --json`.
20
25
  - Read `PROTOCOL.md` before making claims.
21
26
  - If the repo uses parent/child agent guidance, run `orp agents audit --json` so you know `AGENTS.md` and `CLAUDE.md` are aligned before taking a long-running path.
@@ -77,3 +77,49 @@ Use this when the work needs bigger options before it needs tighter polish.
77
77
  - import principles from other domains
78
78
  - keep one unreasonable idea alive long enough to inspect it
79
79
  - prune only after a genuinely bold pass exists
80
+
81
+ ### `granular-breakdown`
82
+
83
+ Use this when the work needs more intentional granularity so the user, agent,
84
+ or future collaborator can actually understand and continue it.
85
+
86
+ - name the whole problem plainly
87
+ - split the work into current state, desired state, and missing bridge
88
+ - order steps by dependency, risk, and comprehension
89
+ - choose one small verification that proves movement
90
+ - compress the result back into a clear summary after the breakdown
91
+
92
+ Recommended commands:
93
+
94
+ ```bash
95
+ orp mode show granular-breakdown --json
96
+ orp mode breakdown granular-breakdown --json
97
+ orp mode nudge granular-breakdown --json
98
+ ```
99
+
100
+ Use `breakdown` when the task needs a real ladder from broad framing to atomic
101
+ subtasks or sub-lemmas. Use `nudge` when the agent only needs a compact reminder
102
+ card for the next pass.
103
+
104
+ Use this regularly as part of the research/development loop when:
105
+
106
+ - the user asks what a feature, command, or error means
107
+ - the plan is correct but too large to hold at once
108
+ - a repo handoff needs to be understandable to the next agent
109
+ - a high-level goal needs to become a safe sequence of small moves
110
+
111
+ The full breakdown ladder is:
112
+
113
+ - whole frame
114
+ - boundary
115
+ - major lanes
116
+ - subclaims
117
+ - atomic obligations
118
+ - dependency ladder
119
+ - active target
120
+ - durable checklist
121
+ - next verification
122
+
123
+ If the breakdown becomes operationally important, promote it into a durable
124
+ checklist artifact with stable IDs, dependencies, statuses, source artifacts,
125
+ falsifier boundaries, and the first active target.
@@ -191,6 +191,17 @@ If you want ORP to remember an ongoing repo path plus a resumable session, add i
191
191
  orp workspace add-tab main --path /absolute/path/to/project --remote-url git@github.com:org/project.git --bootstrap-command "npm install" --resume-command "codex resume <session-id>"
192
192
  ```
193
193
 
194
+ If the same project has more than one active thread, append another session for
195
+ that path:
196
+
197
+ ```bash
198
+ orp workspace add-tab main --path /absolute/path/to/project --title "release hardening" --resume-tool codex --resume-session-id <session-id> --append
199
+ ```
200
+
201
+ ORP keeps the flat `tabs` list for copyable recovery order, and also stores a
202
+ grouped `projects` object so all sessions for the same repo live together under
203
+ `projects[].sessions[]`.
204
+
194
205
  For Claude:
195
206
 
196
207
  ```bash
@@ -652,12 +663,22 @@ When the work feels too linear, too trapped, or too narrow:
652
663
  orp mode nudge sleek-minimal-progressive --json
653
664
  ```
654
665
 
655
- This is optional. It does not override the protocol. It just gives the agent or operator a lightweight perspective shift.
666
+ When the work feels too big, confusing, or hard to sequence:
667
+
668
+ ```bash
669
+ orp mode breakdown granular-breakdown --json
670
+ orp mode nudge granular-breakdown --json
671
+ ```
672
+
673
+ Use `breakdown` for the full broad-to-atomic ladder and `nudge` for a short
674
+ reminder card. Both are optional. They do not override the protocol. They just
675
+ give the agent or operator a clearer thinking scaffold.
656
676
 
657
677
  It is there to help with:
658
678
 
659
679
  - getting unstuck
660
680
  - zooming in or out
681
+ - breaking complex work into smaller intentional steps
661
682
  - rotating the angle of attack
662
683
  - keeping the work fresh without making the workflow sloppy
663
684
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-research-protocol",
3
- "version": "0.4.21",
3
+ "version": "0.4.23",
4
4
  "description": "ORP CLI (Open Research Protocol): workspace ledgers, secrets, scheduling, governed execution, and agent-friendly research workflows.",
5
5
  "license": "MIT",
6
6
  "author": "Fractal Research Group <cody@frg.earth>",
@@ -34,8 +34,15 @@ Add a new saved tab manually:
34
34
  orp workspace ledger add main --path /absolute/path/to/frg-site --resume-command "codex resume 019d348d-5031-78e1-9840-a66deaac33ae"
35
35
  orp workspace add-tab main --path /absolute/path/to/anthropic-lab --resume-tool claude --resume-session-id claude-456
36
36
  orp workspace add-tab main --path /absolute/path/to/orp-web-app --remote-url git@github.com:SproutSeeds/orp-web-app.git --bootstrap-command "pnpm install"
37
+ orp workspace add-tab main --path /absolute/path/to/orp --title "release hardening" --resume-tool codex --resume-session-id 019d32d3-d8b2-7fa2-aaec-c74b5134afd6 --append
37
38
  ```
38
39
 
40
+ When several saved sessions point at the same project path, ORP now also writes
41
+ a grouped `projects` object in the manifest. The flat `tabs` list remains for
42
+ older tools, but agents should prefer `projects[].sessions[]` when they need to
43
+ understand all active Codex or Claude threads for one repo before pruning or
44
+ sunsetting a session.
45
+
39
46
  Remove a saved tab manually:
40
47
 
41
48
  ```bash
@@ -282,6 +282,37 @@ function normalizeStructuredTab(rawTab, index) {
282
282
  };
283
283
  }
284
284
 
285
+ function normalizeStructuredProject(rawProject, projectIndex) {
286
+ if (!rawProject || typeof rawProject !== "object" || Array.isArray(rawProject)) {
287
+ throw new Error(`workspace project ${projectIndex + 1} must be an object`);
288
+ }
289
+
290
+ const projectPath = validateAbsolutePath(rawProject.path, `workspace project ${projectIndex + 1} path`);
291
+ const sessions = Array.isArray(rawProject.sessions) && rawProject.sessions.length > 0 ? rawProject.sessions : [{}];
292
+
293
+ return sessions.map((rawSession, sessionIndex) => {
294
+ if (!rawSession || typeof rawSession !== "object" || Array.isArray(rawSession)) {
295
+ throw new Error(`workspace project ${projectIndex + 1} session ${sessionIndex + 1} must be an object`);
296
+ }
297
+ return normalizeStructuredTab(
298
+ {
299
+ title: rawSession.title ?? rawProject.title,
300
+ path: projectPath,
301
+ remoteUrl: rawSession.remoteUrl ?? rawProject.remoteUrl,
302
+ remoteBranch: rawSession.remoteBranch ?? rawProject.remoteBranch,
303
+ bootstrapCommand: rawSession.bootstrapCommand ?? rawProject.bootstrapCommand,
304
+ resumeCommand: rawSession.resumeCommand,
305
+ resumeTool: rawSession.resumeTool,
306
+ resumeSessionId: rawSession.resumeSessionId ?? rawSession.sessionId,
307
+ codexSessionId: rawSession.codexSessionId,
308
+ claudeSessionId: rawSession.claudeSessionId,
309
+ tmuxSessionName: rawSession.tmuxSessionName,
310
+ },
311
+ sessionIndex,
312
+ );
313
+ });
314
+ }
315
+
285
316
  export function normalizeWorkspaceManifest(rawManifest) {
286
317
  if (!rawManifest || typeof rawManifest !== "object" || Array.isArray(rawManifest)) {
287
318
  throw new Error("workspace manifest must be a JSON object");
@@ -292,11 +323,15 @@ export function normalizeWorkspaceManifest(rawManifest) {
292
323
  throw new Error(`unsupported workspace manifest version: ${version}`);
293
324
  }
294
325
 
295
- if (!Array.isArray(rawManifest.tabs)) {
296
- throw new Error("workspace manifest must include a tabs array");
326
+ const hasProjects = Array.isArray(rawManifest.projects);
327
+ const hasTabs = Array.isArray(rawManifest.tabs);
328
+ if (!hasProjects && !hasTabs) {
329
+ throw new Error("workspace manifest must include a tabs array or projects array");
297
330
  }
298
331
 
299
- const tabs = rawManifest.tabs.map((tab, index) => normalizeStructuredTab(tab, index));
332
+ const tabs = hasProjects
333
+ ? rawManifest.projects.flatMap((project, index) => normalizeStructuredProject(project, index))
334
+ : rawManifest.tabs.map((tab, index) => normalizeStructuredTab(tab, index));
300
335
  return {
301
336
  version,
302
337
  workspaceId: normalizeOptionalString(rawManifest.workspaceId),
@@ -334,6 +369,57 @@ export function deriveBaseTitle(entry) {
334
369
  return path.basename(normalized) || normalized;
335
370
  }
336
371
 
372
+ export function buildWorkspaceProjectGroups(entries = []) {
373
+ const groups = new Map();
374
+
375
+ for (const entry of entries) {
376
+ const projectPath = validateAbsolutePath(entry.path, "workspace project path");
377
+ if (!groups.has(projectPath)) {
378
+ groups.set(projectPath, {
379
+ title: deriveBaseTitle(entry),
380
+ path: projectPath,
381
+ remoteUrl: normalizeOptionalUrl(entry.remoteUrl, "workspace project remoteUrl"),
382
+ remoteBranch: normalizeOptionalString(entry.remoteBranch),
383
+ bootstrapCommand: normalizeOptionalCommand(entry.bootstrapCommand),
384
+ sessions: [],
385
+ });
386
+ }
387
+
388
+ const project = groups.get(projectPath);
389
+ project.remoteUrl = project.remoteUrl || normalizeOptionalUrl(entry.remoteUrl, "workspace project remoteUrl");
390
+ project.remoteBranch = project.remoteBranch || normalizeOptionalString(entry.remoteBranch);
391
+ project.bootstrapCommand = project.bootstrapCommand || normalizeOptionalCommand(entry.bootstrapCommand);
392
+
393
+ const resume = resolveResumeMetadata(entry);
394
+ project.sessions.push(
395
+ Object.fromEntries(
396
+ Object.entries({
397
+ title: normalizeOptionalString(entry.title) || deriveBaseTitle(entry),
398
+ resumeCommand: resume.resumeCommand || undefined,
399
+ resumeTool: resume.resumeTool || undefined,
400
+ resumeSessionId: resume.resumeSessionId || undefined,
401
+ codexSessionId: resume.resumeTool === "codex" ? resume.resumeSessionId || undefined : undefined,
402
+ claudeSessionId: resume.resumeTool === "claude" ? resume.resumeSessionId || undefined : undefined,
403
+ }).filter(([, value]) => value !== undefined),
404
+ ),
405
+ );
406
+ }
407
+
408
+ return [...groups.values()].map((project) =>
409
+ Object.fromEntries(
410
+ Object.entries({
411
+ title: project.title,
412
+ path: project.path,
413
+ remoteUrl: project.remoteUrl || undefined,
414
+ remoteBranch: project.remoteBranch || undefined,
415
+ bootstrapCommand: project.bootstrapCommand || undefined,
416
+ sessionCount: project.sessions.length,
417
+ sessions: project.sessions,
418
+ }).filter(([, value]) => value !== undefined),
419
+ ),
420
+ );
421
+ }
422
+
337
423
  export function deriveTmuxSessionName(entry, options = {}) {
338
424
  if (entry.tmuxSessionName && String(entry.tmuxSessionName).trim().length > 0) {
339
425
  return String(entry.tmuxSessionName).trim();
@@ -97,6 +97,59 @@ function matchPreviousHostedTab(tab, previousTabs) {
97
97
  return match;
98
98
  }
99
99
 
100
+ function buildHostedProjectGroups(tabs) {
101
+ const groups = new Map();
102
+ for (const tab of tabs) {
103
+ const projectRoot = normalizeOptionalString(tab.project_root);
104
+ if (!projectRoot) {
105
+ continue;
106
+ }
107
+ if (!groups.has(projectRoot)) {
108
+ groups.set(projectRoot, {
109
+ title: normalizeOptionalString(tab.repo_label) || path.basename(String(projectRoot).replace(/\/+$/, "")) || projectRoot,
110
+ project_root: projectRoot,
111
+ remote_url: normalizeOptionalString(tab.remote_url),
112
+ remote_branch: normalizeOptionalString(tab.remote_branch),
113
+ bootstrap_command: normalizeOptionalString(tab.bootstrap_command),
114
+ sessions: [],
115
+ });
116
+ }
117
+ const project = groups.get(projectRoot);
118
+ project.remote_url = project.remote_url || normalizeOptionalString(tab.remote_url);
119
+ project.remote_branch = project.remote_branch || normalizeOptionalString(tab.remote_branch);
120
+ project.bootstrap_command = project.bootstrap_command || normalizeOptionalString(tab.bootstrap_command);
121
+ project.sessions.push(
122
+ Object.fromEntries(
123
+ Object.entries({
124
+ tab_id: normalizeOptionalString(tab.tab_id),
125
+ title: normalizeOptionalString(tab.title),
126
+ resume_command: normalizeOptionalString(tab.resume_command),
127
+ resume_tool: normalizeOptionalString(tab.resume_tool),
128
+ resume_session_id: normalizeOptionalString(tab.resume_session_id),
129
+ codex_session_id: normalizeOptionalString(tab.codex_session_id),
130
+ claude_session_id: normalizeOptionalString(tab.claude_session_id),
131
+ status: normalizeOptionalString(tab.status),
132
+ current_task: normalizeOptionalString(tab.current_task),
133
+ }).filter(([, value]) => value !== undefined && value !== null),
134
+ ),
135
+ );
136
+ }
137
+
138
+ return [...groups.values()].map((project) =>
139
+ Object.fromEntries(
140
+ Object.entries({
141
+ title: project.title,
142
+ project_root: project.project_root,
143
+ remote_url: project.remote_url || undefined,
144
+ remote_branch: project.remote_branch || undefined,
145
+ bootstrap_command: project.bootstrap_command || undefined,
146
+ session_count: project.sessions.length,
147
+ sessions: project.sessions,
148
+ }).filter(([, value]) => value !== undefined && value !== null),
149
+ ),
150
+ );
151
+ }
152
+
100
153
  export function buildHostedWorkspaceState(manifest, options = {}) {
101
154
  if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
102
155
  throw new Error("workspace manifest is required to build a hosted workspace state payload");
@@ -172,6 +225,7 @@ export function buildHostedWorkspaceState(manifest, options = {}) {
172
225
  }).filter(([, value]) => value !== undefined && value !== null),
173
226
  );
174
227
  });
228
+ const projects = buildHostedProjectGroups(tabs);
175
229
 
176
230
  const captureContext = Object.fromEntries(
177
231
  Object.entries({
@@ -210,7 +264,9 @@ export function buildHostedWorkspaceState(manifest, options = {}) {
210
264
  captured_at_utc: capturedAt,
211
265
  updated_at_utc: updatedAt,
212
266
  tab_count: tabs.length,
267
+ project_count: projects.length,
213
268
  capture_context: Object.keys(captureContext).length > 0 ? captureContext : undefined,
269
+ projects,
214
270
  tabs,
215
271
  }).filter(([, value]) => value !== undefined && value !== null),
216
272
  );
@@ -5,6 +5,7 @@ import process from "node:process";
5
5
 
6
6
  import {
7
7
  buildDirectCommand,
8
+ buildWorkspaceProjectGroups,
8
9
  deriveBaseTitle,
9
10
  normalizeWorkspaceManifest,
10
11
  parseWorkspaceSource,
@@ -110,6 +111,7 @@ function buildWorkspaceResultTab(tab) {
110
111
 
111
112
  function materializeWorkspaceManifest(manifest) {
112
113
  const normalized = normalizeWorkspaceManifest(manifest);
114
+ const tabs = normalized.tabs.map((tab) => materializeWorkspaceTab(tab));
113
115
  return Object.fromEntries(
114
116
  Object.entries({
115
117
  version: normalized.version,
@@ -117,7 +119,8 @@ function materializeWorkspaceManifest(manifest) {
117
119
  title: normalized.title || undefined,
118
120
  machine: normalized.machine || undefined,
119
121
  capture: normalized.capture || undefined,
120
- tabs: normalized.tabs.map((tab) => materializeWorkspaceTab(tab)),
122
+ projects: buildWorkspaceProjectGroups(normalized.tabs),
123
+ tabs,
121
124
  }).filter(([, value]) => value !== undefined),
122
125
  );
123
126
  }
@@ -670,7 +673,7 @@ Options:
670
673
  --resume-tool <tool> Build the resume command from \`codex\` or \`claude\`
671
674
  --resume-session-id <id> Resume session id to save with the tab
672
675
  --current-codex Save the current \`CODEX_THREAD_ID\` as a Codex resume target
673
- --append Always append a new saved tab instead of updating an existing matching tab
676
+ --append Add another saved session for the same project path instead of updating the existing one
674
677
  --hosted-workspace-id <id> Edit a first-class hosted workspace directly
675
678
  --workspace-file <path> Edit a local structured workspace manifest
676
679
  --json Print the updated workspace edit result as JSON
@@ -681,6 +684,7 @@ Examples:
681
684
  orp workspace add-tab main --here --current-codex
682
685
  orp workspace add-tab main --path /absolute/path/to/new-project --resume-command "codex resume 019d..."
683
686
  orp workspace add-tab main --path /absolute/path/to/new-project --resume-tool claude --resume-session-id claude-456
687
+ orp workspace add-tab main --path /absolute/path/to/new-project --title "second active thread" --resume-tool codex --resume-session-id 019d... --append
684
688
  orp workspace add-tab main --path /absolute/path/to/new-project --remote-url git@github.com:org/new-project.git --bootstrap-command "npm install"
685
689
  `);
686
690
  }
@@ -2,6 +2,7 @@ import process from "node:process";
2
2
  import { createInterface } from "node:readline/promises";
3
3
 
4
4
  import {
5
+ buildWorkspaceProjectGroups,
5
6
  deriveBaseTitle,
6
7
  deriveWorkspaceId,
7
8
  getResumeCommand,
@@ -208,6 +209,7 @@ function serializeWorkspaceManifest(manifest) {
208
209
  }).filter(([, value]) => value !== undefined),
209
210
  ),
210
211
  );
212
+ const projects = buildWorkspaceProjectGroups(manifest.tabs);
211
213
 
212
214
  const normalized = Object.fromEntries(
213
215
  Object.entries({
@@ -215,6 +217,7 @@ function serializeWorkspaceManifest(manifest) {
215
217
  workspaceId: normalizeOptionalString(manifest.workspaceId) ?? undefined,
216
218
  title: normalizeOptionalString(manifest.title) ?? undefined,
217
219
  machine: manifest.machine ?? undefined,
220
+ projects,
218
221
  tabs,
219
222
  }).filter(([, value]) => value !== undefined),
220
223
  );
@@ -5,6 +5,7 @@ import {
5
5
  buildDirectCommand,
6
6
  buildLaunchPlan,
7
7
  buildSetupCommand,
8
+ buildWorkspaceProjectGroups,
8
9
  deriveWorkspaceId,
9
10
  getResumeCommand,
10
11
  parseWorkspaceSource,
@@ -63,6 +64,7 @@ export function buildWorkspaceTabsReport(source, parsed, options = {}) {
63
64
  tmux: false,
64
65
  resume: true,
65
66
  });
67
+ const projectGroups = buildWorkspaceProjectGroups(launchTabs);
66
68
 
67
69
  return {
68
70
  sourceType: source.sourceType,
@@ -72,7 +74,24 @@ export function buildWorkspaceTabsReport(source, parsed, options = {}) {
72
74
  machine: parsed.manifest?.machine || null,
73
75
  parseMode: parsed.parseMode,
74
76
  tabCount: launchTabs.length,
77
+ projectCount: projectGroups.length,
75
78
  skippedCount: parsed.skipped.length,
79
+ projects: projectGroups.map((project) => ({
80
+ ...project,
81
+ sessions: project.sessions.map((session) => ({
82
+ ...session,
83
+ restartCommand: buildDirectCommand(
84
+ {
85
+ path: project.path,
86
+ resumeCommand: session.resumeCommand || null,
87
+ resumeTool: session.resumeTool || null,
88
+ resumeSessionId: session.resumeSessionId || null,
89
+ sessionId: session.resumeSessionId || null,
90
+ },
91
+ { resume: true },
92
+ ),
93
+ })),
94
+ })),
76
95
  tabs: launchTabs.map((tab, index) => ({
77
96
  index: index + 1,
78
97
  title: tab.title,
@@ -116,6 +135,7 @@ export function summarizeWorkspaceTabs(report) {
116
135
  }${report.machine.machineId ? ` [${report.machine.machineId}]` : ""}`,
117
136
  ]
118
137
  : []),
138
+ `Saved projects: ${report.projectCount}`,
119
139
  `Saved tabs: ${report.tabCount}`,
120
140
  `Parse mode: ${report.parseMode}`,
121
141
  "",
@@ -172,6 +192,7 @@ Options:
172
192
 
173
193
  Notes:
174
194
  - This shows the saved tab order plus any stored local path, remote repo, bootstrap command, and \`codex resume ...\` / \`claude --resume ...\` metadata.
195
+ - JSON output includes grouped \`projects[].sessions[]\` so duplicate project paths can be reviewed and sunset together.
175
196
  - The human-readable \`resume:\` line is already copyable and includes the saved \`cd ... && resume ...\` recovery command.
176
197
  - When a tab also has \`remote:\` or \`setup:\` lines, those are the portable cross-machine clues for cloning and preparing the repo on another rig.
177
198
  - The selector can be \`main\`, \`offhand\`, a hosted idea id, a hosted workspace id, a local workspace id, or a saved workspace title/slug.
@@ -243,6 +243,40 @@ test("addTabToManifest asks for a title when multiple saved tabs share a path",
243
243
  );
244
244
  });
245
245
 
246
+ test("normalizeWorkspaceManifest expands grouped project sessions", () => {
247
+ const manifest = normalizeWorkspaceManifest({
248
+ version: "1",
249
+ workspaceId: "main-cody-1",
250
+ title: "main-cody-1",
251
+ projects: [
252
+ {
253
+ title: "orp",
254
+ path: "/Volumes/Code_2TB/code/orp",
255
+ remoteUrl: "git@github.com:SproutSeeds/orp.git",
256
+ sessions: [
257
+ {
258
+ title: "orp release",
259
+ resumeTool: "codex",
260
+ resumeSessionId: "019d32d3-d8b2-7fa2-aaec-c74b5134afd6",
261
+ },
262
+ {
263
+ title: "orp docs",
264
+ resumeCommand: "claude --resume 469d99b2-2997-42bf-a8f5-3812c808ef29",
265
+ },
266
+ ],
267
+ },
268
+ ],
269
+ });
270
+
271
+ assert.equal(manifest.tabs.length, 2);
272
+ assert.equal(manifest.tabs[0]?.path, "/Volumes/Code_2TB/code/orp");
273
+ assert.equal(manifest.tabs[0]?.title, "orp release");
274
+ assert.equal(manifest.tabs[0]?.resumeTool, "codex");
275
+ assert.equal(manifest.tabs[1]?.title, "orp docs");
276
+ assert.equal(manifest.tabs[1]?.resumeTool, "claude");
277
+ assert.equal(manifest.tabs[1]?.sessionId, "469d99b2-2997-42bf-a8f5-3812c808ef29");
278
+ });
279
+
246
280
  test("removeTabsFromManifest can target a saved tab by path and resume session id", () => {
247
281
  const result = removeTabsFromManifest(sampleManifest(), {
248
282
  path: "/Volumes/Code_2TB/code/frg-site",
@@ -328,6 +362,44 @@ test("runWorkspaceAddTab upserts an existing tab and returns the rendered recove
328
362
  });
329
363
  });
330
364
 
365
+ test("runWorkspaceAddTab stores same-project sessions under a grouped project object", async () => {
366
+ await withTempConfigHome(async () => {
367
+ const tempDir = await makeTempDir();
368
+ const manifestPath = path.join(tempDir, "workspace.json");
369
+ await fs.writeFile(manifestPath, `${JSON.stringify(sampleManifest(), null, 2)}\n`, "utf8");
370
+
371
+ const { code, stdout } = await captureStdout(() =>
372
+ runWorkspaceAddTab([
373
+ "--workspace-file",
374
+ manifestPath,
375
+ "--path",
376
+ "/Volumes/Code_2TB/code/orp",
377
+ "--title",
378
+ "orp second session",
379
+ "--resume-tool",
380
+ "codex",
381
+ "--resume-session-id",
382
+ "019d32d3-d8b2-7fa2-aaec-c74b5134afd6",
383
+ "--append",
384
+ "--json",
385
+ ]),
386
+ );
387
+ const payload = JSON.parse(stdout);
388
+ const saved = JSON.parse(await fs.readFile(manifestPath, "utf8"));
389
+
390
+ assert.equal(code, 0);
391
+ assert.equal(payload.action, "add-tab");
392
+ assert.equal(payload.mutation, "added");
393
+ assert.equal(payload.tabCount, 3);
394
+ const orpProject = saved.projects.find((project) => project.path === "/Volumes/Code_2TB/code/orp");
395
+ assert.ok(orpProject);
396
+ assert.equal(orpProject.sessionCount, 2);
397
+ assert.equal(orpProject.sessions[1]?.title, "orp second session");
398
+ assert.equal(orpProject.sessions[1]?.resumeCommand, "codex resume 019d32d3-d8b2-7fa2-aaec-c74b5134afd6");
399
+ assert.equal(saved.tabs.length, 3);
400
+ });
401
+ });
402
+
331
403
  test("runWorkspaceRemoveTab updates a local workspace manifest file", async () => {
332
404
  await withTempConfigHome(async () => {
333
405
  const tempDir = await makeTempDir();
@@ -93,6 +93,10 @@ test("buildWorkspaceTabsReport keeps duplicate titles unique and exposes generic
93
93
  assert.equal(report.workspaceId, "workspace-idea");
94
94
  assert.equal(report.machine?.machineLabel, "Mac Studio");
95
95
  assert.equal(report.tabCount, 3);
96
+ assert.equal(report.projectCount, 2);
97
+ assert.equal(report.projects[0]?.path, "/Volumes/Code_2TB/code/collaboration");
98
+ assert.equal(report.projects[0]?.sessionCount, 2);
99
+ assert.equal(report.projects[0]?.sessions[0]?.restartCommand, "cd '/Volumes/Code_2TB/code/collaboration' && codex resume abc-123");
96
100
  assert.equal(report.tabs[0]?.title, "collaboration");
97
101
  assert.equal(report.tabs[0]?.remoteUrl, "git@github.com:org/collaboration.git");
98
102
  assert.equal(report.tabs[0]?.bootstrapCommand, "npm install");
@@ -163,6 +167,8 @@ test("runWorkspaceTabs prints JSON without launch commands", async () => {
163
167
  assert.equal(parsed.workspaceId, "orp-main");
164
168
  assert.equal(parsed.machine.machineLabel, "Mac Studio");
165
169
  assert.equal(parsed.tabCount, 2);
170
+ assert.equal(parsed.projectCount, 2);
171
+ assert.equal(parsed.projects[0]?.sessions[0]?.restartCommand, "cd '/Volumes/Code_2TB/code/orp' && claude resume claude-999");
166
172
  assert.equal(parsed.tabs[0]?.title, "orp");
167
173
  assert.equal(parsed.tabs[0]?.remoteUrl, "git@github.com:SproutSeeds/orp.git");
168
174
  assert.equal(parsed.tabs[0]?.bootstrapCommand, "npm install");
@@ -26,6 +26,7 @@ ARTIFACT_CLASSES = [
26
26
  "policy",
27
27
  "result",
28
28
  ]
29
+ QUICK_PERFORMANCE_TARGET_MULTIPLIER = 1.5
29
30
  VALID_REQUIREMENT_FIXTURES: dict[str, dict[str, Any]] = {
30
31
  "task": {
31
32
  "schema_version": "1.0.0",
@@ -507,6 +508,54 @@ def _benchmark_cross_domain_corpus() -> dict[str, Any]:
507
508
  }
508
509
 
509
510
 
511
+ def _relax_mean_target(benchmark: dict[str, Any], *, observed_key: str, target_key: str, meets_key: str) -> None:
512
+ targets = benchmark.get("targets")
513
+ observed = benchmark.get("observed")
514
+ meets_targets = benchmark.get("meets_targets")
515
+ if not isinstance(targets, dict) or not isinstance(observed, dict) or not isinstance(meets_targets, dict):
516
+ return
517
+ current_target = targets.get(target_key)
518
+ current_observed = (observed.get(observed_key) or {}).get("mean_ms")
519
+ if not isinstance(current_target, (int, float)) or not isinstance(current_observed, (int, float)):
520
+ return
521
+ next_target = round(float(current_target) * QUICK_PERFORMANCE_TARGET_MULTIPLIER, 3)
522
+ targets[target_key] = next_target
523
+ meets_targets[meets_key] = float(current_observed) < next_target
524
+
525
+
526
+ def _apply_quick_performance_targets(
527
+ *,
528
+ init_benchmark: dict[str, Any],
529
+ roundtrip_benchmark: dict[str, Any],
530
+ corpus_benchmark: dict[str, Any],
531
+ requirement_benchmark: dict[str, Any],
532
+ mutation_stress: dict[str, Any],
533
+ ) -> None:
534
+ _relax_mean_target(init_benchmark, observed_key="init", target_key="init_mean_lt_ms", meets_key="init")
535
+ _relax_mean_target(init_benchmark, observed_key="validate", target_key="validate_mean_lt_ms", meets_key="validate")
536
+ _relax_mean_target(init_benchmark, observed_key="gate_run", target_key="gate_mean_lt_ms", meets_key="gate_run")
537
+ _relax_mean_target(
538
+ roundtrip_benchmark,
539
+ observed_key="scaffold",
540
+ target_key="scaffold_mean_lt_ms",
541
+ meets_key="scaffold",
542
+ )
543
+ _relax_mean_target(
544
+ roundtrip_benchmark,
545
+ observed_key="validate",
546
+ target_key="validate_mean_lt_ms",
547
+ meets_key="validate",
548
+ )
549
+ _relax_mean_target(corpus_benchmark, observed_key="validate", target_key="validate_mean_lt_ms", meets_key="validate")
550
+ _relax_mean_target(
551
+ requirement_benchmark,
552
+ observed_key="validate",
553
+ target_key="validate_mean_lt_ms",
554
+ meets_key="validate",
555
+ )
556
+ _relax_mean_target(mutation_stress, observed_key="validate", target_key="validate_mean_lt_ms", meets_key="validate")
557
+
558
+
510
559
  def _benchmark_requirement_enforcement() -> dict[str, Any]:
511
560
  rows: list[dict[str, Any]] = []
512
561
  validate_times: list[float] = []
@@ -747,7 +796,7 @@ def _gather_metadata() -> dict[str, Any]:
747
796
  }
748
797
 
749
798
 
750
- def build_report(iterations: int) -> dict[str, Any]:
799
+ def build_report(iterations: int, *, quick: bool = False) -> dict[str, Any]:
751
800
  init_benchmark = _benchmark_init_starter(iterations)
752
801
  roundtrip_benchmark = _benchmark_artifact_roundtrip()
753
802
  gate_mode_benchmark = _benchmark_gate_modes()
@@ -756,6 +805,14 @@ def build_report(iterations: int) -> dict[str, Any]:
756
805
  requirement_benchmark = _benchmark_requirement_enforcement()
757
806
  representation_invariance = _benchmark_representation_invariance()
758
807
  mutation_stress = _benchmark_mutation_stress()
808
+ if quick:
809
+ _apply_quick_performance_targets(
810
+ init_benchmark=init_benchmark,
811
+ roundtrip_benchmark=roundtrip_benchmark,
812
+ corpus_benchmark=corpus_benchmark,
813
+ requirement_benchmark=requirement_benchmark,
814
+ mutation_stress=mutation_stress,
815
+ )
759
816
 
760
817
  claims = [
761
818
  {
@@ -901,11 +958,15 @@ def main() -> int:
901
958
  parser = argparse.ArgumentParser(description="Benchmark and validate ORP Reasoning Kernel v0.1")
902
959
  parser.add_argument("--out", default="", help="Optional JSON output path")
903
960
  parser.add_argument("--iterations", type=int, default=5, help="Iterations for bootstrap benchmark")
904
- parser.add_argument("--quick", action="store_true", help="Use a single bootstrap iteration for fast checks")
961
+ parser.add_argument(
962
+ "--quick",
963
+ action="store_true",
964
+ help="Use a single bootstrap iteration and relaxed local timing targets for fast smoke checks",
965
+ )
905
966
  args = parser.parse_args()
906
967
 
907
968
  iterations = 1 if args.quick else max(1, args.iterations)
908
- report = build_report(iterations)
969
+ report = build_report(iterations, quick=args.quick)
909
970
  payload = json.dumps(report, indent=2) + "\n"
910
971
  if args.out:
911
972
  out_path = Path(args.out)