open-research-protocol 0.4.17 → 0.4.19

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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # ORP — Open Research Protocol
2
2
 
3
- Maintained by Fractal Research Group (`frg.earth`).
3
+ Maintained by SproutSeeds. Research stewardship: Fractal Research Group ([frg.earth](https://frg.earth)).
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/open-research-protocol?color=111111&label=npm)](https://www.npmjs.com/package/open-research-protocol)
6
6
  [![npm downloads](https://img.shields.io/npm/dm/open-research-protocol?color=111111&label=downloads)](https://www.npmjs.com/package/open-research-protocol)
@@ -443,6 +443,11 @@ If you are using ORP normally, prefer:
443
443
  Reach for `orp pack ...` when you are doing advanced installs, ORP maintenance,
444
444
  or direct domain-template work.
445
445
 
446
+ Pack installation is pack-owned: `pack.yml` can describe installable
447
+ components, default includes, dependency checks, and report naming. That lets
448
+ ORP consume repo-owned external packs through `--pack-path` without baking
449
+ domain-specific install rules into ORP core.
450
+
446
451
  Install pack configs into a target repo (recommended):
447
452
 
448
453
  ```bash
package/cli/orp.py CHANGED
@@ -8940,7 +8940,7 @@ def _about_payload() -> dict[str, Any]:
8940
8940
  "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.",
8941
8941
  "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.",
8942
8942
  "Project/session linking is a built-in ORP ability exposed through `orp link ...` and stored machine-locally under `.git/orp/link/`.",
8943
- "Hosted secret inventory remains the canonical source of truth, while the local macOS Keychain cache is inspectable and usable through `orp secrets list`, `orp secrets ensure`, `orp secrets keychain-list`, `orp secrets sync-keychain`, and `orp secrets resolve --local-first`.",
8943
+ "Secrets are easiest to understand as saved keys and tokens: humans usually run `orp secrets add ...` and paste the value at the prompt, agents usually pipe the value with `--value-stdin`, and local macOS Keychain caching plus hosted sync are optional layers on top.",
8944
8944
  "Machine runner identity, heartbeat, hosted sync, prompt-job execution, and lease control are built into ORP through `orp runner status`, `orp runner enable`, `orp runner disable`, `orp runner heartbeat`, `orp runner sync`, `orp runner work`, `orp runner cancel`, and `orp runner retry`.",
8945
8945
  "Repo governance is built into ORP through `orp init`, `orp status`, `orp branch start`, `orp checkpoint create`, `orp backup`, `orp ready`, `orp doctor`, and `orp cleanup`.",
8946
8946
  "Hosted workspace operations are built directly into ORP under `orp workspaces ...`, plus the linked auth/ideas/feature/world/checkpoint/agent surfaces.",
@@ -9046,11 +9046,11 @@ def _home_payload(repo_root: Path, config_arg: str) -> dict[str, Any]:
9046
9046
  "command": "orp workspace list",
9047
9047
  },
9048
9048
  {
9049
- "label": "Print exact copyable crash-recovery commands for the main workspace",
9049
+ "label": "Inspect saved paths and exact recovery commands for the main workspace",
9050
9050
  "command": "orp workspace tabs main",
9051
9051
  },
9052
9052
  {
9053
- "label": "Add a new provider key interactively when you need one",
9053
+ "label": "Save a new API key or token interactively when you need one",
9054
9054
  "command": 'orp secrets add --alias <alias> --label "<label>" --provider <provider>',
9055
9055
  },
9056
9056
  {
@@ -9076,10 +9076,6 @@ def _home_payload(repo_root: Path, config_arg: str) -> dict[str, Any]:
9076
9076
  "label": "Inspect the saved workspace ledger inventory",
9077
9077
  "command": "orp workspace list",
9078
9078
  },
9079
- {
9080
- "label": "Print exact copyable crash-recovery commands for the main workspace",
9081
- "command": "orp workspace tabs main",
9082
- },
9083
9079
  {
9084
9080
  "label": "Inspect the saved tabs in the main workspace ledger",
9085
9081
  "command": "orp workspace tabs main",
@@ -9113,11 +9109,11 @@ def _home_payload(repo_root: Path, config_arg: str) -> dict[str, Any]:
9113
9109
  "command": "orp workspaces list --json",
9114
9110
  },
9115
9111
  {
9116
- "label": "Inspect the global ORP secret inventory",
9112
+ "label": "Inspect saved keys and tokens already known to ORP",
9117
9113
  "command": "orp secrets list --json",
9118
9114
  },
9119
9115
  {
9120
- "label": "Reuse a saved provider key or prompt for it and save it for this project",
9116
+ "label": "Reuse a saved key or prompt for it and save it for this project",
9121
9117
  "command": "orp secrets ensure --alias <alias> --provider <provider> --current-project --json",
9122
9118
  },
9123
9119
  {
@@ -9408,11 +9404,11 @@ def _home_payload(repo_root: Path, config_arg: str) -> dict[str, Any]:
9408
9404
  },
9409
9405
  {
9410
9406
  "id": "secrets",
9411
- "description": "Saved API keys and tokens, with create-if-missing flows, optional local macOS Keychain caching, and project-scoped resolution.",
9407
+ "description": "Saved API keys and tokens, with an interactive human flow, a stdin agent flow, optional local macOS Keychain caching, and optional hosted sync.",
9412
9408
  "entrypoints": [
9413
9409
  "orp secrets list --json",
9414
9410
  "orp secrets show <alias-or-id> --json",
9415
- "orp secrets add --alias <alias> --provider <provider> --current-project",
9411
+ 'orp secrets add --alias <alias> --label "<label>" --provider <provider>',
9416
9412
  "orp secrets ensure --alias <alias> --provider <provider> --current-project --json",
9417
9413
  "orp secrets keychain-list --json",
9418
9414
  "orp secrets keychain-show <alias-or-id> --json",
@@ -9614,14 +9610,34 @@ def _render_home_screen(payload: dict[str, Any]) -> str:
9614
9610
  lines.append("")
9615
9611
  lines.append("Command Families")
9616
9612
  if isinstance(abilities, list) and abilities:
9617
- for row in abilities:
9613
+ ability_map = {
9614
+ str(row.get("id", "")).strip(): row
9615
+ for row in abilities
9616
+ if isinstance(row, dict) and str(row.get("id", "")).strip()
9617
+ }
9618
+ visible_ability_ids = [
9619
+ "workspace",
9620
+ "secrets",
9621
+ "governance",
9622
+ "frontier",
9623
+ "schedule",
9624
+ "modes",
9625
+ "hosted",
9626
+ "discover",
9627
+ ]
9628
+ shown = 0
9629
+ for ability_id in visible_ability_ids:
9630
+ row = ability_map.get(ability_id)
9618
9631
  if not isinstance(row, dict):
9619
9632
  continue
9620
- ability_id = str(row.get("id", "")).strip()
9621
9633
  desc = _truncate(str(row.get("description", "")).strip())
9622
9634
  lines.append(f" - {ability_id}")
9623
9635
  if desc:
9624
9636
  lines.append(f" {desc}")
9637
+ shown += 1
9638
+ remaining = max(len(ability_map) - shown, 0)
9639
+ if remaining:
9640
+ lines.append(f" - ... and {remaining} more in `orp about --json`")
9625
9641
 
9626
9642
  lines.append("")
9627
9643
  lines.append("Collaboration")
@@ -9652,7 +9668,7 @@ def _render_home_screen(payload: dict[str, Any]) -> str:
9652
9668
  lines.append("")
9653
9669
  lines.append("Quick Actions")
9654
9670
  if isinstance(quick_actions, list):
9655
- for row in quick_actions[:14]:
9671
+ for row in quick_actions[:10]:
9656
9672
  if not isinstance(row, dict):
9657
9673
  continue
9658
9674
  label = str(row.get("label", "")).strip()
@@ -9661,7 +9677,7 @@ def _render_home_screen(payload: dict[str, Any]) -> str:
9661
9677
  continue
9662
9678
  lines.append(f" - {label}")
9663
9679
  lines.append(f" {command}")
9664
- remaining = max(len(quick_actions) - 14, 0)
9680
+ remaining = max(len(quick_actions) - 10, 0)
9665
9681
  if remaining:
9666
9682
  lines.append(f" - ... and {remaining} more in `orp home --json`")
9667
9683
 
@@ -18544,10 +18560,31 @@ def build_parser() -> argparse.ArgumentParser:
18544
18560
  add_json_flag(s_youtube_inspect)
18545
18561
  s_youtube_inspect.set_defaults(func=cmd_youtube_inspect, json_output=False)
18546
18562
 
18547
- s_secrets = sub.add_parser("secrets", help="Hosted secret store and project binding operations")
18563
+ s_secrets = sub.add_parser(
18564
+ "secrets",
18565
+ help="Save and reuse API keys and tokens locally, with optional hosted sync",
18566
+ description=(
18567
+ "ORP secrets are easiest to understand as saved keys and tokens.\n\n"
18568
+ "Human flow:\n"
18569
+ " 1. Run `orp secrets add ...`\n"
18570
+ " 2. Paste the value when ORP prompts `Secret value:`\n"
18571
+ " 3. Later run `orp secrets list` or `orp secrets resolve ...`\n\n"
18572
+ "Agent flow:\n"
18573
+ " - Pipe the value with `--value-stdin` instead of typing it interactively.\n\n"
18574
+ "Local macOS Keychain caching and hosted sync are optional layers on top."
18575
+ ),
18576
+ epilog=(
18577
+ "Examples:\n"
18578
+ " orp secrets add --alias openai-primary --label \"OpenAI Primary\" --provider openai\n"
18579
+ " printf '%s' 'sk-...' | orp secrets add --alias openai-primary --label \"OpenAI Primary\" --provider openai --value-stdin\n"
18580
+ " orp secrets list\n"
18581
+ " orp secrets resolve openai-primary --reveal"
18582
+ ),
18583
+ formatter_class=argparse.RawTextHelpFormatter,
18584
+ )
18548
18585
  secrets_sub = s_secrets.add_subparsers(dest="secrets_cmd", required=True)
18549
18586
 
18550
- s_secrets_list = secrets_sub.add_parser("list", help="List hosted secrets for the current user")
18587
+ s_secrets_list = secrets_sub.add_parser("list", help="List saved secrets known to ORP")
18551
18588
  s_secrets_list.add_argument("--provider", default="", help="Optional provider filter")
18552
18589
  add_secret_scope_flags(s_secrets_list)
18553
18590
  s_secrets_list.add_argument(
@@ -18559,13 +18596,16 @@ def build_parser() -> argparse.ArgumentParser:
18559
18596
  add_json_flag(s_secrets_list)
18560
18597
  s_secrets_list.set_defaults(func=cmd_secrets_list, json_output=False)
18561
18598
 
18562
- s_secrets_show = secrets_sub.add_parser("show", help="Show one hosted secret by alias or id")
18599
+ s_secrets_show = secrets_sub.add_parser("show", help="Show one saved secret by alias or id")
18563
18600
  s_secrets_show.add_argument("secret_ref", help="Secret alias or id")
18564
18601
  add_base_url_flag(s_secrets_show)
18565
18602
  add_json_flag(s_secrets_show)
18566
18603
  s_secrets_show.set_defaults(func=cmd_secrets_show, json_output=False)
18567
18604
 
18568
- s_secrets_add = secrets_sub.add_parser("add", help="Create a hosted secret")
18605
+ s_secrets_add = secrets_sub.add_parser(
18606
+ "add",
18607
+ help="Save a new secret; ORP prompts for the value unless you pass --value-stdin",
18608
+ )
18569
18609
  s_secrets_add.add_argument("--alias", required=True, help="Stable secret alias")
18570
18610
  s_secrets_add.add_argument("--label", required=True, help="Human label for the secret")
18571
18611
  s_secrets_add.add_argument("--provider", required=True, help="Provider slug, for example openai")
@@ -18596,7 +18636,7 @@ def build_parser() -> argparse.ArgumentParser:
18596
18636
 
18597
18637
  s_secrets_ensure = secrets_sub.add_parser(
18598
18638
  "ensure",
18599
- help="Use an existing hosted secret by alias or create it and bind it when missing",
18639
+ help="Reuse a saved secret or prompt for it and save it when missing",
18600
18640
  )
18601
18641
  s_secrets_ensure.add_argument("--alias", required=True, help="Stable secret alias")
18602
18642
  s_secrets_ensure.add_argument("--label", default="", help="Human label for create-if-missing flows")
@@ -18637,7 +18677,7 @@ def build_parser() -> argparse.ArgumentParser:
18637
18677
 
18638
18678
  s_secrets_keychain_list = secrets_sub.add_parser(
18639
18679
  "keychain-list",
18640
- help="List locally cached macOS Keychain secrets known to ORP on this machine",
18680
+ help="List local macOS Keychain copies known to ORP on this machine",
18641
18681
  )
18642
18682
  s_secrets_keychain_list.add_argument("--provider", default="", help="Optional provider filter")
18643
18683
  add_secret_scope_flags(s_secrets_keychain_list)
@@ -18646,7 +18686,7 @@ def build_parser() -> argparse.ArgumentParser:
18646
18686
 
18647
18687
  s_secrets_keychain_show = secrets_sub.add_parser(
18648
18688
  "keychain-show",
18649
- help="Show one locally cached macOS Keychain secret by alias or id",
18689
+ help="Show one local macOS Keychain copy by alias or id",
18650
18690
  )
18651
18691
  s_secrets_keychain_show.add_argument("secret_ref", help="Secret alias or id")
18652
18692
  s_secrets_keychain_show.add_argument(
@@ -18659,7 +18699,7 @@ def build_parser() -> argparse.ArgumentParser:
18659
18699
 
18660
18700
  s_secrets_sync_keychain = secrets_sub.add_parser(
18661
18701
  "sync-keychain",
18662
- help="Sync hosted secrets into the local macOS Keychain",
18702
+ help="Copy one saved secret into the local macOS Keychain",
18663
18703
  )
18664
18704
  s_secrets_sync_keychain.add_argument("secret_ref", nargs="?", default="", help="Optional secret alias or id")
18665
18705
  s_secrets_sync_keychain.add_argument("--provider", default="", help="Provider slug for project-scoped sync")
@@ -18673,7 +18713,7 @@ def build_parser() -> argparse.ArgumentParser:
18673
18713
  add_json_flag(s_secrets_sync_keychain)
18674
18714
  s_secrets_sync_keychain.set_defaults(func=cmd_secrets_sync_keychain, json_output=False)
18675
18715
 
18676
- s_secrets_update = secrets_sub.add_parser("update", help="Update one hosted secret")
18716
+ s_secrets_update = secrets_sub.add_parser("update", help="Update one saved secret")
18677
18717
  s_secrets_update.add_argument("secret_ref", help="Secret alias or id")
18678
18718
  s_secrets_update.add_argument("--alias", default=None, help="New alias")
18679
18719
  s_secrets_update.add_argument("--label", default=None, help="New label")
@@ -18702,13 +18742,13 @@ def build_parser() -> argparse.ArgumentParser:
18702
18742
  add_json_flag(s_secrets_update)
18703
18743
  s_secrets_update.set_defaults(func=cmd_secrets_update, json_output=False)
18704
18744
 
18705
- s_secrets_archive = secrets_sub.add_parser("archive", help="Archive one hosted secret")
18745
+ s_secrets_archive = secrets_sub.add_parser("archive", help="Archive one saved secret")
18706
18746
  s_secrets_archive.add_argument("secret_ref", help="Secret alias or id")
18707
18747
  add_base_url_flag(s_secrets_archive)
18708
18748
  add_json_flag(s_secrets_archive)
18709
18749
  s_secrets_archive.set_defaults(func=cmd_secrets_archive, json_output=False)
18710
18750
 
18711
- s_secrets_bind = secrets_sub.add_parser("bind", help="Bind one secret to a hosted project/world")
18751
+ s_secrets_bind = secrets_sub.add_parser("bind", help="Bind one saved secret to a hosted project/world")
18712
18752
  s_secrets_bind.add_argument("secret_ref", help="Secret alias or id")
18713
18753
  add_secret_scope_flags(s_secrets_bind)
18714
18754
  s_secrets_bind.add_argument("--purpose", default="", help="Optional project usage note")
@@ -18729,7 +18769,7 @@ def build_parser() -> argparse.ArgumentParser:
18729
18769
 
18730
18770
  s_secrets_resolve = secrets_sub.add_parser(
18731
18771
  "resolve",
18732
- help="Resolve one hosted secret by alias/id or by provider plus project scope",
18772
+ help="Resolve one saved secret by alias/id or by provider plus project scope",
18733
18773
  )
18734
18774
  s_secrets_resolve.add_argument("secret_ref", nargs="?", default="", help="Optional secret alias or id")
18735
18775
  s_secrets_resolve.add_argument("--provider", default="", help="Provider slug for project-scoped resolution")
@@ -39,6 +39,7 @@ Canonical fields:
39
39
  - `pack_id`: stable id
40
40
  - `name`, `version`, `description`
41
41
  - `orp_version_min`: optional compatibility floor
42
+ - `install`: optional install contract for `orp pack install`
42
43
  - `variables`: render-time variables (for example `TARGET_REPO_ROOT`)
43
44
  - `templates`: available config templates
44
45
 
@@ -80,6 +81,10 @@ orp pack fetch \
80
81
  --install-target /path/to/repo
81
82
  ```
82
83
 
84
+ Repo-owned local packs can also be installed directly with `--pack-path` as
85
+ long as their `pack.yml` includes an `install` block describing components,
86
+ default includes, and dependency-audit paths.
87
+
83
88
  This installs rendered config files and writes a dependency audit report:
84
89
 
85
90
  - `./orp.erdos-catalog-sync.yml`
@@ -218,6 +223,9 @@ orp erdos sync --problem-id 857 --problem-id 20
218
223
  - Packs can live in this repo (`packs/`) or external repos.
219
224
  - Users can copy/install packs without changing ORP core.
220
225
  - Version packs independently (for example `0.1.0`, `0.2.0`).
226
+ - Repo-owned packs can ship their own install metadata in `pack.yml`, so ORP
227
+ can install external domain packs without hardcoding those domains into ORP
228
+ core.
221
229
 
222
230
  ## Quality guidance
223
231
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-research-protocol",
3
- "version": "0.4.17",
3
+ "version": "0.4.19",
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>",
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import process from "node:process";
4
4
 
5
5
  import {
6
- buildCanonicalResumeCommand,
6
+ buildDirectCommand,
7
7
  deriveBaseTitle,
8
8
  normalizeWorkspaceManifest,
9
9
  parseWorkspaceSource,
@@ -44,6 +44,17 @@ function validateAbsolutePath(value, label) {
44
44
  return normalized;
45
45
  }
46
46
 
47
+ function resolveCurrentCodexResume() {
48
+ const sessionId = normalizeOptionalString(process.env.CODEX_THREAD_ID);
49
+ if (!sessionId) {
50
+ throw new Error("`--current-codex` requires `CODEX_THREAD_ID` in the current environment.");
51
+ }
52
+ return {
53
+ resumeTool: "codex",
54
+ resumeSessionId: sessionId,
55
+ };
56
+ }
57
+
47
58
  function serializeManifest(manifest) {
48
59
  return `${JSON.stringify(materializeWorkspaceManifest(manifest), null, 2)}\n`;
49
60
  }
@@ -63,6 +74,26 @@ function materializeWorkspaceTab(tab) {
63
74
  );
64
75
  }
65
76
 
77
+ function buildWorkspaceResultTab(tab) {
78
+ if (!tab) {
79
+ return null;
80
+ }
81
+ const materialized = materializeWorkspaceTab(tab);
82
+ return {
83
+ ...materialized,
84
+ restartCommand: buildDirectCommand(
85
+ {
86
+ path: materialized.path,
87
+ resumeCommand: materialized.resumeCommand || null,
88
+ resumeTool: materialized.resumeTool || null,
89
+ sessionId: materialized.resumeSessionId || null,
90
+ resumeSessionId: materialized.resumeSessionId || null,
91
+ },
92
+ { resume: true },
93
+ ),
94
+ };
95
+ }
96
+
66
97
  function materializeWorkspaceManifest(manifest) {
67
98
  const normalized = normalizeWorkspaceManifest(manifest);
68
99
  return Object.fromEntries(
@@ -122,10 +153,16 @@ function normalizeEditableManifest(source, parsed) {
122
153
  return normalizeWorkspaceManifest(baseManifest);
123
154
  }
124
155
 
125
- function parseLedgerSelectorArgs(argv = [], { commandName, requirePath = false, requireSelector = true } = {}) {
156
+ function parseLedgerSelectorArgs(
157
+ argv = [],
158
+ { commandName, requirePath = false, requireSelector = true, allowAppend = false, allowHere = false, allowCurrentCodex = false } = {},
159
+ ) {
126
160
  const options = {
127
161
  json: false,
128
162
  all: false,
163
+ append: false,
164
+ here: false,
165
+ currentCodex: false,
129
166
  };
130
167
 
131
168
  for (let index = 0; index < argv.length; index += 1) {
@@ -143,6 +180,18 @@ function parseLedgerSelectorArgs(argv = [], { commandName, requirePath = false,
143
180
  options.all = true;
144
181
  continue;
145
182
  }
183
+ if (allowAppend && arg === "--append") {
184
+ options.append = true;
185
+ continue;
186
+ }
187
+ if (allowHere && arg === "--here") {
188
+ options.here = true;
189
+ continue;
190
+ }
191
+ if (allowCurrentCodex && arg === "--current-codex") {
192
+ options.currentCodex = true;
193
+ continue;
194
+ }
146
195
  if (arg.startsWith("--")) {
147
196
  const next = argv[index + 1];
148
197
  if (next == null || next.startsWith("--")) {
@@ -190,12 +239,24 @@ function parseLedgerSelectorArgs(argv = [], { commandName, requirePath = false,
190
239
  if (requireSelector && !options.ideaId && !options.workspaceFile && !options.hostedWorkspaceId) {
191
240
  throw new Error(`Provide a workspace selector for \`${commandName}\`.`);
192
241
  }
193
- if (requirePath && !options.path) {
194
- throw new Error(`--path is required for \`${commandName}\`.`);
242
+ if (options.here && options.path) {
243
+ throw new Error("Use either `--path` or `--here`, not both.");
244
+ }
245
+ if (requirePath && !options.path && !options.here) {
246
+ throw new Error(`\`--path\` or \`--here\` is required for \`${commandName}\`.`);
247
+ }
248
+ if (options.here) {
249
+ options.path = path.resolve(process.cwd());
195
250
  }
196
251
  if (options.path) {
197
252
  options.path = validateAbsolutePath(options.path, "--path");
198
253
  }
254
+ if (options.currentCodex) {
255
+ if (options.resumeCommand || options.resumeTool || options.resumeSessionId) {
256
+ throw new Error("`--current-codex` cannot be combined with explicit resume metadata.");
257
+ }
258
+ Object.assign(options, resolveCurrentCodexResume());
259
+ }
199
260
  if (options.index != null) {
200
261
  const parsed = Number.parseInt(String(options.index), 10);
201
262
  if (!Number.isInteger(parsed) || parsed < 1) {
@@ -212,6 +273,9 @@ export function parseWorkspaceAddTabArgs(argv = []) {
212
273
  commandName: "orp workspace add-tab",
213
274
  requirePath: true,
214
275
  requireSelector: true,
276
+ allowAppend: true,
277
+ allowHere: true,
278
+ allowCurrentCodex: true,
215
279
  });
216
280
  }
217
281
 
@@ -306,45 +370,107 @@ export function addTabToManifest(manifest, options = {}) {
306
370
  ...manifest,
307
371
  tabs: manifest.tabs.map((tab) => ({ ...tab })),
308
372
  });
309
- const resume = resolveResumeMetadata({
310
- resumeCommand: options.resumeCommand,
311
- resumeTool: options.resumeTool,
312
- resumeSessionId: options.resumeSessionId,
313
- });
314
- const nextTab = Object.fromEntries(
315
- Object.entries({
316
- title: normalizeOptionalString(options.title) || undefined,
317
- path: validateAbsolutePath(options.path, "--path"),
318
- resumeCommand: resume.resumeCommand || undefined,
319
- resumeTool: resume.resumeTool || undefined,
320
- resumeSessionId: resume.resumeSessionId || undefined,
321
- codexSessionId: resume.resumeTool === "codex" ? resume.resumeSessionId || undefined : undefined,
322
- claudeSessionId: resume.resumeTool === "claude" ? resume.resumeSessionId || undefined : undefined,
323
- }).filter(([, value]) => value !== undefined),
373
+ const normalizedPath = validateAbsolutePath(options.path, "--path");
374
+ const normalizedTitle = normalizeOptionalString(options.title);
375
+ const explicitResumeRequested = Boolean(
376
+ normalizeOptionalString(options.resumeCommand) ||
377
+ normalizeOptionalString(options.resumeTool) ||
378
+ normalizeOptionalString(options.resumeSessionId),
324
379
  );
380
+ const requestedResume = explicitResumeRequested
381
+ ? resolveResumeMetadata({
382
+ resumeCommand: options.resumeCommand,
383
+ resumeTool: options.resumeTool,
384
+ resumeSessionId: options.resumeSessionId,
385
+ })
386
+ : null;
387
+
388
+ const buildTab = (existingTab = null) => {
389
+ const existingResume = resolveResumeMetadata(existingTab || {});
390
+ const chosenResume = requestedResume || existingResume;
391
+ return Object.fromEntries(
392
+ Object.entries({
393
+ title: normalizedTitle || normalizeOptionalString(existingTab?.title) || undefined,
394
+ path: normalizedPath,
395
+ resumeCommand: chosenResume.resumeCommand || undefined,
396
+ resumeTool: chosenResume.resumeTool || undefined,
397
+ resumeSessionId: chosenResume.resumeSessionId || undefined,
398
+ codexSessionId: chosenResume.resumeTool === "codex" ? chosenResume.resumeSessionId || undefined : undefined,
399
+ claudeSessionId: chosenResume.resumeTool === "claude" ? chosenResume.resumeSessionId || undefined : undefined,
400
+ }).filter(([, value]) => value !== undefined),
401
+ );
402
+ };
403
+
404
+ const nextTab = buildTab();
405
+ if (options.append) {
406
+ nextManifest.tabs.push(nextTab);
407
+ return {
408
+ manifest: normalizeWorkspaceManifest(nextManifest),
409
+ added: true,
410
+ updated: false,
411
+ unchanged: false,
412
+ mutation: "added",
413
+ tab: nextTab,
414
+ };
415
+ }
325
416
 
326
- const duplicate = nextManifest.tabs.find((tab) => {
327
- const existingResume = resolveResumeMetadata(tab);
328
- return (
329
- tab.path === nextTab.path &&
330
- normalizeOptionalString(tab.title) === normalizeOptionalString(nextTab.title) &&
331
- existingResume.resumeCommand === (nextTab.resumeCommand || null)
417
+ const pathMatchIndexes = nextManifest.tabs
418
+ .map((tab, index) => (tab.path === normalizedPath ? index : -1))
419
+ .filter((index) => index >= 0);
420
+ let matchedIndexes = [];
421
+
422
+ if (normalizedTitle) {
423
+ matchedIndexes = pathMatchIndexes.filter(
424
+ (index) => normalizeOptionalString(nextManifest.tabs[index]?.title) === normalizedTitle,
332
425
  );
333
- });
426
+ if (matchedIndexes.length === 0 && pathMatchIndexes.length === 1) {
427
+ matchedIndexes = pathMatchIndexes;
428
+ }
429
+ } else if (pathMatchIndexes.length > 0) {
430
+ const uniqueTitles = new Set(
431
+ pathMatchIndexes.map((index) => normalizeOptionalString(nextManifest.tabs[index]?.title) || ""),
432
+ );
433
+ if (pathMatchIndexes.length === 1 || uniqueTitles.size <= 1) {
434
+ matchedIndexes = pathMatchIndexes;
435
+ } else {
436
+ throw new Error(
437
+ "Multiple saved tabs already use this path. Re-run with `--title` to target one tab or `--append` to add another.",
438
+ );
439
+ }
440
+ }
334
441
 
335
- if (duplicate) {
442
+ if (matchedIndexes.length === 0) {
443
+ nextManifest.tabs.push(nextTab);
336
444
  return {
337
- manifest: nextManifest,
338
- added: false,
339
- tab: duplicate,
445
+ manifest: normalizeWorkspaceManifest(nextManifest),
446
+ added: true,
447
+ updated: false,
448
+ unchanged: false,
449
+ mutation: "added",
450
+ tab: nextTab,
340
451
  };
341
452
  }
342
453
 
343
- nextManifest.tabs.push(nextTab);
454
+ const [primaryIndex, ...duplicateIndexes] = matchedIndexes;
455
+ const currentTab = nextManifest.tabs[primaryIndex];
456
+ const updatedTab = buildTab(currentTab);
457
+ const currentMaterialized = JSON.stringify(materializeWorkspaceTab(currentTab));
458
+ const updatedMaterialized = JSON.stringify(materializeWorkspaceTab(updatedTab));
459
+ nextManifest.tabs[primaryIndex] = updatedTab;
460
+
461
+ if (duplicateIndexes.length > 0) {
462
+ const removalSet = new Set(duplicateIndexes);
463
+ nextManifest.tabs = nextManifest.tabs.filter((_, index) => !removalSet.has(index));
464
+ }
465
+
466
+ const changed = currentMaterialized !== updatedMaterialized || duplicateIndexes.length > 0;
344
467
  return {
345
468
  manifest: normalizeWorkspaceManifest(nextManifest),
346
- added: true,
347
- tab: nextTab,
469
+ added: false,
470
+ updated: changed,
471
+ unchanged: !changed,
472
+ mutation: changed ? "updated" : "unchanged",
473
+ tab: updatedTab,
348
474
  };
349
475
  }
350
476
 
@@ -481,16 +607,19 @@ function printWorkspaceAddTabHelp() {
481
607
  console.log(`ORP workspace add-tab
482
608
 
483
609
  Usage:
484
- orp workspace add-tab <name-or-id> --path <absolute-path> [--title <title>] [--resume-command <text> | --resume-tool <codex|claude> --resume-session-id <id>] [--json]
485
- orp workspace add-tab --hosted-workspace-id <workspace-id> --path <absolute-path> [--json]
486
- orp workspace add-tab --workspace-file <path> --path <absolute-path> [--json]
610
+ orp workspace add-tab <name-or-id> (--path <absolute-path> | --here) [--title <title>] [--resume-command <text> | --resume-tool <codex|claude> --resume-session-id <id> | --current-codex] [--append] [--json]
611
+ orp workspace add-tab --hosted-workspace-id <workspace-id> (--path <absolute-path> | --here) [--json]
612
+ orp workspace add-tab --workspace-file <path> (--path <absolute-path> | --here) [--json]
487
613
 
488
614
  Options:
489
615
  --path <absolute-path> Add this local project path to the saved workspace
616
+ --here Use the current working directory as the saved path
490
617
  --title <title> Optional saved tab title
491
618
  --resume-command <text> Exact saved resume command, like \`codex resume ...\` or \`claude --resume ...\`
492
619
  --resume-tool <tool> Build the resume command from \`codex\` or \`claude\`
493
620
  --resume-session-id <id> Resume session id to save with the tab
621
+ --current-codex Save the current \`CODEX_THREAD_ID\` as a Codex resume target
622
+ --append Always append a new saved tab instead of updating an existing matching tab
494
623
  --hosted-workspace-id <id> Edit a first-class hosted workspace directly
495
624
  --workspace-file <path> Edit a local structured workspace manifest
496
625
  --json Print the updated workspace edit result as JSON
@@ -498,6 +627,7 @@ Options:
498
627
 
499
628
  Examples:
500
629
  orp workspace add-tab main --path /absolute/path/to/new-project
630
+ orp workspace add-tab main --here --current-codex
501
631
  orp workspace add-tab main --path /absolute/path/to/new-project --resume-command "codex resume 019d..."
502
632
  orp workspace add-tab main --path /absolute/path/to/new-project --resume-tool claude --resume-session-id claude-456
503
633
  `);
@@ -560,14 +690,15 @@ function summarizeWorkspaceLedgerMutation(result) {
560
690
  const lines = [
561
691
  `Workspace: ${result.workspaceTitle || result.workspaceId || "workspace"}`,
562
692
  `Action: ${result.action}`,
693
+ ...(result.action === "add-tab" && result.mutation ? [`Result: ${result.mutation}`] : []),
563
694
  `Saved tabs: ${result.tabCount}`,
564
695
  ];
565
696
 
566
697
  if (result.action === "add-tab") {
567
- lines.push(`Added: ${result.tab?.title || path.basename(result.tab?.path || "") || result.tab?.path}`);
698
+ lines.push(`Tab: ${result.tab?.title || path.basename(result.tab?.path || "") || result.tab?.path}`);
568
699
  lines.push(`Path: ${result.tab?.path}`);
569
- if (result.tab?.resumeCommand) {
570
- lines.push(`Resume: ${result.tab.resumeCommand}`);
700
+ if (result.tab?.resumeCommand && result.tab?.restartCommand) {
701
+ lines.push(`Resume: ${result.tab.restartCommand}`);
571
702
  }
572
703
  } else if (result.action === "remove-tab") {
573
704
  lines.push(`Removed: ${result.removedTabs.length}`);
@@ -603,9 +734,10 @@ async function runWorkspaceLedgerMutation(options, mutate, action) {
603
734
  action,
604
735
  workspaceId: finalManifest.workspaceId,
605
736
  workspaceTitle: finalManifest.title || source.title || null,
737
+ mutation: mutated.mutation || null,
606
738
  tabCount: finalManifest.tabs.length,
607
- tab: mutated.tab ? materializeWorkspaceTab(mutated.tab) : null,
608
- removedTabs: (mutated.removedTabs || []).map((tab) => materializeWorkspaceTab(tab)),
739
+ tab: buildWorkspaceResultTab(mutated.tab),
740
+ removedTabs: (mutated.removedTabs || []).map((tab) => buildWorkspaceResultTab(tab)),
609
741
  persistedTo: persisted.persistedTo,
610
742
  ideaId: persisted.ideaId || null,
611
743
  workspaceSourceId: persisted.workspaceId || null,
@@ -1,4 +1,3 @@
1
- import { runWorkspaceCommands } from "./commands.js";
2
1
  import { runWorkspaceAddTab, runWorkspaceCreate, runWorkspaceRemoveTab } from "./ledger.js";
3
2
  import { runWorkspaceList } from "./list.js";
4
3
  import { runWorkspaceSlot } from "./slot.js";
@@ -10,36 +9,36 @@ function printWorkspaceHelp() {
10
9
 
11
10
  Usage:
12
11
  orp workspace create <title-slug> [--workspace-file <path>] [--slot <main|offhand>] [--path <absolute-path>] [--resume-command <text> | --resume-tool <codex|claude> --resume-session-id <id>] [--json]
13
- orp workspace ledger <name-or-id> [--json]
14
- orp workspace ledger add <name-or-id> --path <absolute-path> [--title <title>] [--resume-command <text> | --resume-tool <codex|claude> --resume-session-id <id>] [--json]
15
- orp workspace ledger remove <name-or-id> (--index <n> | --path <absolute-path> | --title <title> | --resume-session-id <id> | --resume-command <text>) [--all] [--json]
12
+ orp workspace list [--json]
16
13
  orp workspace tabs <name-or-id> [--json]
17
14
  orp workspace tabs --hosted-workspace-id <workspace-id> [--json]
18
15
  orp workspace tabs --notes-file <path> [--json]
19
16
  orp workspace tabs --workspace-file <path> [--json]
20
- orp workspace add-tab <name-or-id> --path <absolute-path> [--title <title>] [--resume-command <text> | --resume-tool <codex|claude> --resume-session-id <id>] [--json]
17
+ orp workspace add-tab <name-or-id> (--path <absolute-path> | --here) [--title <title>] [--resume-command <text> | --resume-tool <codex|claude> --resume-session-id <id> | --current-codex] [--append] [--json]
21
18
  orp workspace remove-tab <name-or-id> (--index <n> | --path <absolute-path> | --title <title> | --resume-session-id <id> | --resume-command <text>) [--all] [--json]
22
- orp workspace list [--json]
23
19
  orp workspace slot <list|set|clear> ...
24
20
  orp workspace sync <name-or-id> [--workspace-file <path> | --notes-file <path>] [--dry-run] [--json]
21
+ orp workspace ledger <name-or-id> [--json]
22
+ orp workspace ledger add <name-or-id> (--path <absolute-path> | --here) [--title <title>] [--resume-command <text> | --resume-tool <codex|claude> --resume-session-id <id> | --current-codex] [--append] [--json]
23
+ orp workspace ledger remove <name-or-id> (--index <n> | --path <absolute-path> | --title <title> | --resume-session-id <id> | --resume-command <text>) [--all] [--json]
25
24
  orp workspace -h
26
25
 
27
26
  Commands:
28
27
  create Create a local workspace ledger so ORP works without a hosted account
29
- ledger Terminal-native saved workspace ledger flow: inspect, add, and remove saved paths plus resume commands
28
+ list List one merged inventory of hosted ORP workspaces and local manifests
30
29
  tabs List the saved tabs inside a workspace with copyable resume/recovery lines
31
30
  add-tab Add a saved tab/path/session to the workspace ledger directly
32
31
  remove-tab Remove one or more saved tabs from the workspace ledger directly
33
- list List one merged inventory of hosted ORP workspaces and local manifests
34
32
  slot Assign and inspect named workspace slots like main and offhand
35
33
  sync Post a CLI-authored workspace manifest back to the hosted ORP idea
34
+ ledger Compatibility alias for the same tabs/add/remove ledger flow
36
35
 
37
36
  Notes:
38
- - Local-only usage works: create a workspace with \`orp workspace create <title-slug>\`, then use \`orp workspace add-tab ...\`, \`orp workspace tabs ...\`, and \`orp workspace remove-tab ...\` without authenticating.
39
- - The ledger-first flow is: \`orp workspace ledger <workspace>\`, \`orp workspace ledger add ...\`, \`orp workspace ledger remove ...\`, and \`orp workspace tabs <workspace>\`.
37
+ - Local-only usage works: create a workspace with \`orp workspace create <title-slug>\`, then use \`orp workspace tabs ...\`, \`orp workspace add-tab ...\`, and \`orp workspace remove-tab ...\` without authenticating.
40
38
  - Use \`orp workspace list\` for the combined hosted + local workspace inventory.
41
39
  - Use \`orp workspace tabs <workspace>\` when you want saved paths plus copyable \`cd ... && codex resume ...\` / \`claude --resume ...\` recovery lines.
42
40
  - Use \`orp workspace add-tab ...\` and \`orp workspace remove-tab ...\` when you want to edit the saved workspace ledger explicitly from Terminal.app or any other shell.
41
+ - If you prefer the older ledger-prefixed wording, \`orp workspace ledger\`, \`orp workspace ledger add\`, and \`orp workspace ledger remove\` stay available as aliases.
43
42
  - \`main\` and \`offhand\` are reserved slot selectors; use \`orp workspace slot set ...\` to assign them.
44
43
  - Syncing or editing a hosted workspace writes a managed local cache on this Mac.
45
44
  - \`<name-or-id>\` can be a saved workspace title, workspace id, idea id, or local tracked workspace title/id.
@@ -47,17 +46,18 @@ Notes:
47
46
  Examples:
48
47
  orp workspace create main-cody-1
49
48
  orp workspace create main-cody-1 --slot main
50
- orp workspace ledger main
51
- orp workspace ledger add main --path /absolute/path/to/new-project --resume-command "codex resume 019d..."
52
- orp workspace ledger remove main --title frg-site
49
+ orp workspace list
53
50
  orp workspace tabs main-cody-1
51
+ orp workspace add-tab main --here --current-codex
54
52
  orp workspace add-tab main --path /absolute/path/to/new-project --resume-command "codex resume 019d..."
55
53
  orp workspace remove-tab main --path /absolute/path/to/frg-site --resume-session-id 019d348d-5031-78e1-9840-a66deaac33ae
56
54
  orp workspace slot set main main-cody-1
57
55
  orp workspace slot set offhand research-lab
58
56
  orp workspace slot list
57
+ orp workspace ledger main
58
+ orp workspace ledger add main --path /absolute/path/to/new-project --resume-command "codex resume 019d..."
59
+ orp workspace ledger remove main --title frg-site
59
60
  orp workspace tabs --hosted-workspace-id ws_orp_main
60
- orp workspace list
61
61
  orp workspace sync main --workspace-file ./workspace.json
62
62
  `);
63
63
  }
@@ -97,6 +97,32 @@ test("parseWorkspaceAddTabArgs accepts explicit resume metadata", () => {
97
97
  assert.equal(parsed.json, true);
98
98
  });
99
99
 
100
+ test("parseWorkspaceAddTabArgs resolves --here and --current-codex", () => {
101
+ const originalThreadId = process.env.CODEX_THREAD_ID;
102
+ const originalCwd = process.cwd();
103
+ process.env.CODEX_THREAD_ID = "019d4f24-c8ba-78b2-a726-48b1ce9f0fe9";
104
+ process.chdir("/Volumes/Code_2TB/code/orp");
105
+ try {
106
+ const parsed = parseWorkspaceAddTabArgs([
107
+ "main",
108
+ "--here",
109
+ "--current-codex",
110
+ ]);
111
+
112
+ assert.equal(parsed.ideaId, "main");
113
+ assert.equal(parsed.path, "/Volumes/Code_2TB/code/orp");
114
+ assert.equal(parsed.resumeTool, "codex");
115
+ assert.equal(parsed.resumeSessionId, "019d4f24-c8ba-78b2-a726-48b1ce9f0fe9");
116
+ } finally {
117
+ process.chdir(originalCwd);
118
+ if (originalThreadId == null) {
119
+ delete process.env.CODEX_THREAD_ID;
120
+ } else {
121
+ process.env.CODEX_THREAD_ID = originalThreadId;
122
+ }
123
+ }
124
+ });
125
+
100
126
  test("parseWorkspaceCreateArgs validates slug titles and optional seed metadata", () => {
101
127
  const parsed = parseWorkspaceCreateArgs([
102
128
  "main-cody-1",
@@ -144,6 +170,63 @@ test("addTabToManifest canonicalizes Claude resume commands from tool plus sessi
144
170
  assert.equal(result.manifest.tabs[2]?.sessionId, "claude-456");
145
171
  });
146
172
 
173
+ test("addTabToManifest updates an existing matching tab instead of appending a duplicate", () => {
174
+ const result = addTabToManifest(sampleManifest(), {
175
+ path: "/Volumes/Code_2TB/code/orp",
176
+ title: "orp",
177
+ resumeTool: "codex",
178
+ resumeSessionId: "019d4f24-c8ba-78b2-a726-48b1ce9f0fe9",
179
+ });
180
+
181
+ assert.equal(result.added, false);
182
+ assert.equal(result.updated, true);
183
+ assert.equal(result.mutation, "updated");
184
+ assert.equal(result.manifest.tabs.length, 2);
185
+ assert.equal(result.manifest.tabs[0]?.resumeCommand, "codex resume 019d4f24-c8ba-78b2-a726-48b1ce9f0fe9");
186
+ });
187
+
188
+ test("addTabToManifest preserves existing resume metadata when no new resume is provided", () => {
189
+ const result = addTabToManifest(sampleManifest(), {
190
+ path: "/Volumes/Code_2TB/code/frg-site",
191
+ title: "frg-site",
192
+ });
193
+
194
+ assert.equal(result.added, false);
195
+ assert.equal(result.updated, false);
196
+ assert.equal(result.unchanged, true);
197
+ assert.equal(result.mutation, "unchanged");
198
+ assert.equal(result.manifest.tabs.length, 2);
199
+ assert.equal(result.manifest.tabs[1]?.resumeCommand, "codex resume 019d348d-5031-78e1-9840-a66deaac33ae");
200
+ });
201
+
202
+ test("addTabToManifest asks for a title when multiple saved tabs share a path", () => {
203
+ const manifest = normalizeWorkspaceManifest({
204
+ version: "1",
205
+ workspaceId: "main-cody-1",
206
+ title: "main-cody-1",
207
+ tabs: [
208
+ {
209
+ title: "longevity-research",
210
+ path: "/Volumes/Code_2TB/code/longevity-research",
211
+ },
212
+ {
213
+ title: "longevity-research (2)",
214
+ path: "/Volumes/Code_2TB/code/longevity-research",
215
+ },
216
+ ],
217
+ });
218
+
219
+ assert.throws(
220
+ () =>
221
+ addTabToManifest(manifest, {
222
+ path: "/Volumes/Code_2TB/code/longevity-research",
223
+ resumeTool: "codex",
224
+ resumeSessionId: "019d4f24-c8ba-78b2-a726-48b1ce9f0fe9",
225
+ }),
226
+ /Multiple saved tabs already use this path/,
227
+ );
228
+ });
229
+
147
230
  test("removeTabsFromManifest can target a saved tab by path and resume session id", () => {
148
231
  const result = removeTabsFromManifest(sampleManifest(), {
149
232
  path: "/Volumes/Code_2TB/code/frg-site",
@@ -186,6 +269,42 @@ test("runWorkspaceAddTab updates a local workspace manifest file", async () => {
186
269
  });
187
270
  });
188
271
 
272
+ test("runWorkspaceAddTab upserts an existing tab and returns the rendered recovery command", async () => {
273
+ await withTempConfigHome(async () => {
274
+ const tempDir = await makeTempDir();
275
+ const manifestPath = path.join(tempDir, "workspace.json");
276
+ await fs.writeFile(manifestPath, `${JSON.stringify(sampleManifest(), null, 2)}\n`, "utf8");
277
+
278
+ const { code, stdout } = await captureStdout(() =>
279
+ runWorkspaceAddTab([
280
+ "--workspace-file",
281
+ manifestPath,
282
+ "--path",
283
+ "/Volumes/Code_2TB/code/orp",
284
+ "--resume-tool",
285
+ "codex",
286
+ "--resume-session-id",
287
+ "019d4f24-c8ba-78b2-a726-48b1ce9f0fe9",
288
+ "--json",
289
+ ]),
290
+ );
291
+ const payload = JSON.parse(stdout);
292
+ const saved = JSON.parse(await fs.readFile(manifestPath, "utf8"));
293
+
294
+ assert.equal(code, 0);
295
+ assert.equal(payload.action, "add-tab");
296
+ assert.equal(payload.mutation, "updated");
297
+ assert.equal(payload.tabCount, 2);
298
+ assert.equal(payload.tab.resumeCommand, "codex resume 019d4f24-c8ba-78b2-a726-48b1ce9f0fe9");
299
+ assert.equal(
300
+ payload.tab.restartCommand,
301
+ "cd '/Volumes/Code_2TB/code/orp' && codex resume 019d4f24-c8ba-78b2-a726-48b1ce9f0fe9",
302
+ );
303
+ assert.equal(saved.tabs.length, 2);
304
+ assert.equal(saved.tabs[0]?.resumeCommand, "codex resume 019d4f24-c8ba-78b2-a726-48b1ce9f0fe9");
305
+ });
306
+ });
307
+
189
308
  test("runWorkspaceRemoveTab updates a local workspace manifest file", async () => {
190
309
  await withTempConfigHome(async () => {
191
310
  const tempDir = await makeTempDir();
@@ -29,10 +29,16 @@ test("runOrpWorkspaceCommand shows the ledger-first help surface", async () => {
29
29
  const { code, stdout } = await captureStdout(() => runOrpWorkspaceCommand(["-h"]));
30
30
 
31
31
  assert.equal(code, 0);
32
+ assert.match(stdout, /orp workspace list \[--json\]/);
33
+ assert.match(stdout, /orp workspace add-tab <name-or-id>/);
34
+ assert.match(stdout, /--here/);
35
+ assert.match(stdout, /--current-codex/);
36
+ assert.match(stdout, /orp workspace remove-tab <name-or-id>/);
32
37
  assert.match(stdout, /orp workspace ledger <name-or-id>/);
33
38
  assert.match(stdout, /orp workspace ledger add <name-or-id>/);
34
39
  assert.match(stdout, /orp workspace ledger remove <name-or-id>/);
35
40
  assert.match(stdout, /orp workspace tabs <name-or-id>/);
41
+ assert.match(stdout, /Compatibility alias for the same tabs\/add\/remove ledger flow/);
36
42
  });
37
43
 
38
44
  test("runOrpWorkspaceCommand routes ledger help to the tabs help surface", async () => {
@@ -129,3 +129,57 @@ templates:
129
129
  output_hint: orp.erdos-catalog-sync.yml
130
130
  default_profiles:
131
131
  - erdos_catalog_sync_active
132
+
133
+ install:
134
+ default_includes:
135
+ - catalog
136
+ - live_compare
137
+ - problem857
138
+ report_name: orp.erdos.pack-install-report.md
139
+ components:
140
+ catalog:
141
+ template_id: erdos_problems_catalog_sync
142
+ output_name: orp.erdos-catalog-sync.yml
143
+ description: Erdos catalog sync (all/open/closed/active snapshots).
144
+ live_compare:
145
+ template_id: sunflower_live_compare_suite
146
+ output_name: orp.erdos-live-compare.yml
147
+ description: Side-by-side atomic-board compare for Problems 857/20/367.
148
+ required_paths:
149
+ - analysis/problem857_counting_gateboard.json
150
+ - analysis/problem20_k3_gateboard.json
151
+ - analysis/problem367_sharp_gateboard.json
152
+ - scripts/problem857_ops_board.py
153
+ - scripts/problem20_ops_board.py
154
+ - scripts/problem367_ops_board.py
155
+ - scripts/frontier_status.py
156
+ problem857:
157
+ template_id: sunflower_problem857_discovery
158
+ output_name: orp.erdos-problem857.yml
159
+ description: Problem 857 discovery profile (board refresh/ready/spec/lean/frontier).
160
+ required_paths:
161
+ - analysis/problem857_counting_gateboard.json
162
+ - docs/PROBLEM857_COUNTING_OPS_BOARD.md
163
+ - orchestrator/v2/scopes/problem_857.yaml
164
+ - orchestrator/problem857_public_spec_check.py
165
+ - scripts/problem857_ops_board.py
166
+ - scripts/frontier_status.py
167
+ - sunflower_lean
168
+ governance:
169
+ template_id: sunflower_mathlib_pr_governance
170
+ output_name: orp.erdos-mathlib-pr-governance.yml
171
+ description: Mathlib PR governance profile set (pre-open, draft-readiness, full flow).
172
+ required_paths:
173
+ - docs/MATHLIB_SUBMISSION_CHECKLIST.md
174
+ - docs/MATHLIB_DRAFT_PR_TEMPLATE.md
175
+ - docs/MATHLIB_ISSUE_VIABILITY_GATE.md
176
+ - docs/UPSTREAM_PR_LANE.md
177
+ - analysis/UPSTREAM_PR_PLAN.yaml
178
+ - scripts/upstream-pr-plan.py
179
+ - scripts/upstream-pr-lane.sh
180
+ - scripts/mathlib-issue-viability-gate.py
181
+ - scripts/mathlib-naturality-snippet.sh
182
+ - scripts/mathlib-issue-local-gate.sh
183
+ - scripts/mathlib-tighten-fine-tooth-gate.sh
184
+ - scripts/mathlib-ready-to-draft-gate.sh
185
+ - scripts/mathlib-pr-body-preflight.py
@@ -144,3 +144,20 @@ templates:
144
144
  output_hint: orp.external-pr-feedback-hardening.yml
145
145
  default_profiles:
146
146
  - external_feedback_hardening
147
+
148
+ install:
149
+ default_includes:
150
+ - governance
151
+ - feedback_hardening
152
+ report_name: orp.external-pr.pack-install-report.md
153
+ components:
154
+ governance:
155
+ template_id: oss_pr_governance
156
+ output_name: orp.external-pr-governance.yml
157
+ description: Generic external contribution governance profiles (watch/select, pre-open, local-readiness, draft transition, draft lifecycle, full flow).
158
+ required_paths:
159
+ - analysis/PR_DRAFT_BODY.md
160
+ feedback_hardening:
161
+ template_id: oss_feedback_hardening
162
+ output_name: orp.external-pr-feedback-hardening.yml
163
+ description: Generic maintainer-feedback hardening profile.
@@ -176,3 +176,28 @@ templates:
176
176
  output_hint: orp.issue-smashers-feedback-hardening.yml
177
177
  default_profiles:
178
178
  - issue_smashers_feedback_hardening
179
+
180
+ install:
181
+ default_includes:
182
+ - workspace
183
+ - feedback_hardening
184
+ report_name: orp.issue-smashers.pack-install-report.md
185
+ components:
186
+ workspace:
187
+ template_id: issue_smashers_workspace
188
+ output_name: orp.issue-smashers.yml
189
+ description: Opinionated issue-smashers workspace and external contribution governance profiles.
190
+ required_paths:
191
+ - issue-smashers/README.md
192
+ - issue-smashers/WORKSPACE_RULES.md
193
+ - issue-smashers/setup-issue-smashers.sh
194
+ - issue-smashers/analysis/ISSUE_SMASHERS_WATCHLIST.json
195
+ - issue-smashers/analysis/ISSUE_SMASHERS_STATUS.md
196
+ - issue-smashers/analysis/PR_DRAFT_BODY.md
197
+ feedback_hardening:
198
+ template_id: issue_smashers_feedback_hardening
199
+ output_name: orp.issue-smashers-feedback-hardening.yml
200
+ description: Issue-smashers maintainer-feedback hardening profile.
201
+ required_paths:
202
+ - issue-smashers/WORKSPACE_RULES.md
203
+ - issue-smashers/analysis/ISSUE_SMASHERS_STATUS.md
@@ -24,7 +24,7 @@ from typing import Any
24
24
  import yaml
25
25
 
26
26
 
27
- PACK_SPECS: dict[str, dict[str, Any]] = {
27
+ LEGACY_PACK_SPECS: dict[str, dict[str, Any]] = {
28
28
  "erdos-open-problems": {
29
29
  "default_includes": ["catalog", "live_compare", "problem857"],
30
30
  "report_name": "orp.erdos.pack-install-report.md",
@@ -1267,18 +1267,99 @@ def _load_yaml(path: Path) -> dict[str, Any]:
1267
1267
  return payload
1268
1268
 
1269
1269
 
1270
- def _pack_spec(pack_id: str) -> dict[str, Any]:
1271
- spec = PACK_SPECS.get(pack_id)
1270
+ def _legacy_pack_spec(pack_id: str) -> dict[str, Any]:
1271
+ spec = LEGACY_PACK_SPECS.get(pack_id)
1272
1272
  if not isinstance(spec, dict):
1273
1273
  raise RuntimeError(f"unsupported pack for install flow: {pack_id}")
1274
1274
  return spec
1275
1275
 
1276
1276
 
1277
- def _pack_components(pack_id: str) -> dict[str, dict[str, Any]]:
1278
- components = _pack_spec(pack_id).get("components", {})
1277
+ def _pack_install_spec(pack_meta: dict[str, Any], pack_id: str) -> dict[str, Any]:
1278
+ install = pack_meta.get("install")
1279
+ if isinstance(install, dict):
1280
+ return install
1281
+ return _legacy_pack_spec(pack_id)
1282
+
1283
+
1284
+ def _pack_templates(pack_meta: dict[str, Any]) -> dict[str, dict[str, Any]]:
1285
+ raw = pack_meta.get("templates", {})
1286
+ if not isinstance(raw, dict):
1287
+ return {}
1288
+ out: dict[str, dict[str, Any]] = {}
1289
+ for key, value in raw.items():
1290
+ if isinstance(key, str) and isinstance(value, dict):
1291
+ out[key] = value
1292
+ return out
1293
+
1294
+
1295
+ def _normalize_install_component(
1296
+ component_key: str,
1297
+ raw_component: dict[str, Any],
1298
+ *,
1299
+ templates: dict[str, dict[str, Any]],
1300
+ ) -> dict[str, Any]:
1301
+ template_id = str(raw_component.get("template_id", "")).strip()
1302
+ if not template_id:
1303
+ raise RuntimeError(f"install component missing template_id: {component_key}")
1304
+
1305
+ template_meta = templates.get(template_id)
1306
+ if not isinstance(template_meta, dict):
1307
+ raise RuntimeError(
1308
+ f"install component {component_key} references unknown template_id: {template_id}"
1309
+ )
1310
+
1311
+ output_name = str(raw_component.get("output_name") or template_meta.get("output_hint") or "").strip()
1312
+ if not output_name:
1313
+ raise RuntimeError(
1314
+ f"install component {component_key} is missing output_name and template {template_id} has no output_hint"
1315
+ )
1316
+
1317
+ description = str(raw_component.get("description") or template_meta.get("description") or "").strip()
1318
+ required_paths_raw = raw_component.get("required_paths", [])
1319
+ if required_paths_raw is None:
1320
+ required_paths: list[str] = []
1321
+ elif isinstance(required_paths_raw, list):
1322
+ required_paths = [str(path) for path in required_paths_raw]
1323
+ else:
1324
+ raise RuntimeError(f"install component required_paths must be a list: {component_key}")
1325
+
1326
+ return {
1327
+ "template_id": template_id,
1328
+ "output_name": output_name,
1329
+ "description": description,
1330
+ "required_paths": required_paths,
1331
+ }
1332
+
1333
+
1334
+ def _pack_components(pack_meta: dict[str, Any], pack_id: str) -> dict[str, dict[str, Any]]:
1335
+ install = _pack_install_spec(pack_meta, pack_id)
1336
+ components = install.get("components", {})
1279
1337
  if not isinstance(components, dict) or not components:
1280
1338
  raise RuntimeError(f"pack has no installable components: {pack_id}")
1281
- return components
1339
+
1340
+ templates = _pack_templates(pack_meta)
1341
+ out: dict[str, dict[str, Any]] = {}
1342
+ for key, value in components.items():
1343
+ if not isinstance(key, str) or not isinstance(value, dict):
1344
+ raise RuntimeError(f"invalid install component entry in pack {pack_id}: {key!r}")
1345
+ out[key] = _normalize_install_component(key, value, templates=templates)
1346
+ return out
1347
+
1348
+
1349
+ def _pack_default_includes(pack_meta: dict[str, Any], pack_id: str) -> list[str]:
1350
+ install = _pack_install_spec(pack_meta, pack_id)
1351
+ raw = install.get("default_includes", [])
1352
+ if not isinstance(raw, list):
1353
+ return []
1354
+ return [str(x) for x in raw if isinstance(x, str)]
1355
+
1356
+
1357
+ def _pack_report_name(pack_meta: dict[str, Any], pack_id: str) -> str:
1358
+ install = _pack_install_spec(pack_meta, pack_id)
1359
+ raw = install.get("report_name")
1360
+ if isinstance(raw, str) and raw.strip():
1361
+ return raw.strip()
1362
+ return f"orp.{pack_id}.pack-install-report.md"
1282
1363
 
1283
1364
 
1284
1365
  def _validate_var(raw: str) -> str:
@@ -2153,15 +2234,13 @@ def main() -> int:
2153
2234
  pack_id = str(pack_meta.get("pack_id", args.pack_id))
2154
2235
  pack_version = str(pack_meta.get("version", "unknown"))
2155
2236
  generated_at_utc = _now_utc()
2156
- components = _pack_components(pack_id)
2237
+ components = _pack_components(pack_meta, pack_id)
2157
2238
  effective_vars = _vars_map(pack_meta, list(args.var or []))
2158
2239
  problem857_source_mode = _problem857_source_mode(effective_vars)
2159
2240
 
2160
2241
  includes = list(args.include or [])
2161
2242
  if not includes:
2162
- default_includes = _pack_spec(pack_id).get("default_includes", [])
2163
- if isinstance(default_includes, list):
2164
- includes = [str(x) for x in default_includes if isinstance(x, str)]
2243
+ includes = _pack_default_includes(pack_meta, pack_id)
2165
2244
  if not includes:
2166
2245
  includes = sorted(components.keys())
2167
2246
 
@@ -2227,7 +2306,7 @@ def main() -> int:
2227
2306
  else:
2228
2307
  report_path = report_path.resolve()
2229
2308
  else:
2230
- report_name = str(_pack_spec(pack_id).get("report_name", f"orp.{pack_id}.pack-install-report.md"))
2309
+ report_name = _pack_report_name(pack_meta, pack_id)
2231
2310
  report_path = (target_repo_root / report_name).resolve()
2232
2311
 
2233
2312
  _write_report(
@@ -34,6 +34,9 @@
34
34
  "orp_version_min": {
35
35
  "type": "string"
36
36
  },
37
+ "install": {
38
+ "$ref": "#/$defs/install"
39
+ },
37
40
  "variables": {
38
41
  "type": "object",
39
42
  "additionalProperties": {
@@ -90,6 +93,51 @@
90
93
  }
91
94
  }
92
95
  }
96
+ },
97
+ "install": {
98
+ "type": "object",
99
+ "additionalProperties": false,
100
+ "properties": {
101
+ "default_includes": {
102
+ "type": "array",
103
+ "items": {
104
+ "type": "string"
105
+ }
106
+ },
107
+ "report_name": {
108
+ "type": "string"
109
+ },
110
+ "components": {
111
+ "type": "object",
112
+ "additionalProperties": {
113
+ "$ref": "#/$defs/installComponent"
114
+ }
115
+ }
116
+ }
117
+ },
118
+ "installComponent": {
119
+ "type": "object",
120
+ "additionalProperties": false,
121
+ "required": [
122
+ "template_id"
123
+ ],
124
+ "properties": {
125
+ "template_id": {
126
+ "type": "string"
127
+ },
128
+ "output_name": {
129
+ "type": "string"
130
+ },
131
+ "description": {
132
+ "type": "string"
133
+ },
134
+ "required_paths": {
135
+ "type": "array",
136
+ "items": {
137
+ "type": "string"
138
+ }
139
+ }
140
+ }
93
141
  }
94
142
  }
95
143
  }