open-research-protocol 0.4.27 → 0.4.29

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/cli/orp.py CHANGED
@@ -141,6 +141,8 @@ FRONTIER_TERMINAL_STATUSES = {"complete", "completed", "done", "skipped", "termi
141
141
  YOUTUBE_SOURCE_SCHEMA_VERSION = "1.0.0"
142
142
  EXCHANGE_REPORT_SCHEMA_VERSION = "1.0.0"
143
143
  RESEARCH_RUN_SCHEMA_VERSION = "1.0.0"
144
+ SECRET_SPEND_POLICY_SCHEMA_VERSION = "1.0.0"
145
+ RESEARCH_SPEND_LEDGER_SCHEMA_VERSION = "1.0.0"
144
146
  PROJECT_CONTEXT_SCHEMA_VERSION = "1.0.0"
145
147
  HYGIENE_POLICY_SCHEMA_VERSION = "1.0.0"
146
148
  MAINTENANCE_STATE_SCHEMA_VERSION = "1.0.0"
@@ -922,6 +924,45 @@ def _keychain_secret_registry_path() -> Path:
922
924
  return _orp_user_dir() / "secrets-keychain.json"
923
925
 
924
926
 
927
+ def _research_spend_ledger_path() -> Path:
928
+ return _orp_user_dir() / "research-spend-ledger.json"
929
+
930
+
931
+ def _research_spend_ledger_template() -> dict[str, Any]:
932
+ return {
933
+ "schema_version": RESEARCH_SPEND_LEDGER_SCHEMA_VERSION,
934
+ "records": [],
935
+ }
936
+
937
+
938
+ def _load_research_spend_ledger() -> dict[str, Any]:
939
+ path = _research_spend_ledger_path()
940
+ if not path.exists():
941
+ return _research_spend_ledger_template()
942
+ try:
943
+ payload = json.loads(path.read_text(encoding="utf-8"))
944
+ except Exception:
945
+ return _research_spend_ledger_template()
946
+ if not isinstance(payload, dict):
947
+ return _research_spend_ledger_template()
948
+ records = payload.get("records")
949
+ return {
950
+ "schema_version": str(payload.get("schema_version", RESEARCH_SPEND_LEDGER_SCHEMA_VERSION)).strip()
951
+ or RESEARCH_SPEND_LEDGER_SCHEMA_VERSION,
952
+ "records": [row for row in records if isinstance(row, dict)] if isinstance(records, list) else [],
953
+ }
954
+
955
+
956
+ def _save_research_spend_ledger(ledger: dict[str, Any]) -> None:
957
+ records = ledger.get("records")
958
+ payload = {
959
+ "schema_version": str(ledger.get("schema_version", RESEARCH_SPEND_LEDGER_SCHEMA_VERSION)).strip()
960
+ or RESEARCH_SPEND_LEDGER_SCHEMA_VERSION,
961
+ "records": [row for row in records if isinstance(row, dict)] if isinstance(records, list) else [],
962
+ }
963
+ _write_json(_research_spend_ledger_path(), payload)
964
+
965
+
925
966
  def _keychain_supported() -> bool:
926
967
  return sys.platform == "darwin" or os.environ.get("ORP_KEYCHAIN_ALLOW_NON_DARWIN", "").strip() == "1"
927
968
 
@@ -10046,6 +10087,462 @@ def _effective_remote_context(
10046
10087
  }
10047
10088
 
10048
10089
 
10090
+ def _command_preview(args: Sequence[str]) -> str:
10091
+ return " ".join(shlex.quote(str(arg)) for arg in args)
10092
+
10093
+
10094
+ def _tool_path(tool_name: str) -> str:
10095
+ return shutil.which(str(tool_name or "").strip()) or ""
10096
+
10097
+
10098
+ def _run_checked_process(
10099
+ args: Sequence[str],
10100
+ *,
10101
+ cwd: Path,
10102
+ context: str,
10103
+ ) -> subprocess.CompletedProcess[str]:
10104
+ command = [str(arg) for arg in args]
10105
+ try:
10106
+ proc = subprocess.run(
10107
+ command,
10108
+ cwd=str(cwd),
10109
+ capture_output=True,
10110
+ text=True,
10111
+ )
10112
+ except FileNotFoundError as exc:
10113
+ raise RuntimeError(f"{context} requires `{command[0]}` on PATH.") from exc
10114
+ if proc.returncode != 0:
10115
+ detail = proc.stderr.strip() or proc.stdout.strip() or f"exit code {proc.returncode}"
10116
+ raise RuntimeError(f"{context} failed: {detail}")
10117
+ return proc
10118
+
10119
+
10120
+ def _run_optional_process(
10121
+ args: Sequence[str],
10122
+ *,
10123
+ cwd: Path,
10124
+ ) -> dict[str, Any]:
10125
+ command = [str(arg) for arg in args]
10126
+ try:
10127
+ proc = subprocess.run(
10128
+ command,
10129
+ cwd=str(cwd),
10130
+ capture_output=True,
10131
+ text=True,
10132
+ )
10133
+ except FileNotFoundError as exc:
10134
+ return {
10135
+ "ok": False,
10136
+ "command": _command_preview(command),
10137
+ "returncode": 127,
10138
+ "detail": str(exc),
10139
+ }
10140
+ detail = proc.stderr.strip() or proc.stdout.strip()
10141
+ return {
10142
+ "ok": proc.returncode == 0,
10143
+ "command": _command_preview(command),
10144
+ "returncode": int(proc.returncode),
10145
+ "detail": _truncate(detail, limit=600) if detail else "",
10146
+ }
10147
+
10148
+
10149
+ def _init_startup_enabled(args: argparse.Namespace) -> bool:
10150
+ bool_flags = [
10151
+ "project_startup",
10152
+ "private_github",
10153
+ "track_workspace_main",
10154
+ "with_clawdad",
10155
+ "current_codex",
10156
+ "workspace_append",
10157
+ ]
10158
+ text_flags = [
10159
+ "codex_session_id",
10160
+ "workspace_title",
10161
+ "workspace_bootstrap_command",
10162
+ "workspace_name",
10163
+ "clawdad_slug",
10164
+ "clawdad_description",
10165
+ ]
10166
+ if any(bool(getattr(args, name, False)) for name in bool_flags):
10167
+ return True
10168
+ return any(str(getattr(args, name, "") or "").strip() for name in text_flags if name != "workspace_name")
10169
+
10170
+
10171
+ def _init_startup_context(args: argparse.Namespace) -> dict[str, Any]:
10172
+ enabled = _init_startup_enabled(args)
10173
+ project_startup = bool(getattr(args, "project_startup", False))
10174
+ github_repo = str(getattr(args, "github_repo", "") or "").strip()
10175
+ clawdad_path = _tool_path("clawdad")
10176
+ return {
10177
+ "enabled": enabled,
10178
+ "project_startup": project_startup,
10179
+ "dry_run": bool(getattr(args, "startup_dry_run", False)),
10180
+ "tools": {
10181
+ "gh": _tool_path("gh"),
10182
+ "clawdad": clawdad_path,
10183
+ "orp": _tool_path("orp"),
10184
+ },
10185
+ "github": {
10186
+ "requested": bool(getattr(args, "private_github", False)) or bool(project_startup and github_repo),
10187
+ "repo": "",
10188
+ "remote_url": "",
10189
+ "action": "not_requested",
10190
+ },
10191
+ "workspace": {
10192
+ "requested": bool(
10193
+ getattr(args, "track_workspace_main", False)
10194
+ or project_startup
10195
+ or getattr(args, "current_codex", False)
10196
+ or str(getattr(args, "codex_session_id", "") or "").strip()
10197
+ or str(getattr(args, "workspace_title", "") or "").strip()
10198
+ or str(getattr(args, "workspace_bootstrap_command", "") or "").strip()
10199
+ ),
10200
+ "workspace": str(getattr(args, "workspace_name", "") or "main").strip() or "main",
10201
+ "action": "not_requested",
10202
+ },
10203
+ "clawdad": {
10204
+ "requested": bool(getattr(args, "with_clawdad", False)) or bool(project_startup and clawdad_path),
10205
+ "available": bool(clawdad_path),
10206
+ "action": "not_requested",
10207
+ },
10208
+ "codex": {
10209
+ "requested_current": bool(getattr(args, "current_codex", False)),
10210
+ "session_id": "",
10211
+ "source": "",
10212
+ },
10213
+ "commands": [],
10214
+ "warnings": [],
10215
+ "next_actions": [],
10216
+ "ok": True,
10217
+ }
10218
+
10219
+
10220
+ def _startup_codex_context(args: argparse.Namespace, startup: dict[str, Any]) -> dict[str, str]:
10221
+ explicit_session = str(getattr(args, "codex_session_id", "") or "").strip()
10222
+ env_session = str(os.environ.get("CODEX_THREAD_ID", "") or "").strip()
10223
+ requested_current = bool(getattr(args, "current_codex", False))
10224
+ session_id = explicit_session or (env_session if requested_current else "")
10225
+ source = "explicit" if explicit_session else ("CODEX_THREAD_ID" if session_id else "")
10226
+ if explicit_session and requested_current and env_session and explicit_session != env_session:
10227
+ startup["warnings"].append(
10228
+ "both --codex-session-id and --current-codex were provided; using the explicit session id."
10229
+ )
10230
+ if requested_current and not session_id:
10231
+ startup["warnings"].append("CODEX_THREAD_ID is not set; workspace path will be tracked without a Codex resume target.")
10232
+ startup["next_actions"].append("rerun from an active Codex session with --current-codex, or pass --codex-session-id <id>")
10233
+ startup["codex"] = {
10234
+ "requested_current": requested_current,
10235
+ "session_id": session_id,
10236
+ "source": source,
10237
+ }
10238
+ return {"session_id": session_id, "source": source}
10239
+
10240
+
10241
+ def _setup_private_github_startup_remote(
10242
+ *,
10243
+ repo_root: Path,
10244
+ github_repo_raw: str,
10245
+ dry_run: bool,
10246
+ ) -> dict[str, Any]:
10247
+ github_repo = _normalize_github_repo(github_repo_raw)
10248
+ if not github_repo:
10249
+ raise RuntimeError("--private-github requires --github-repo owner/repo.")
10250
+ remote_url = _synthesized_github_remote_url(github_repo)
10251
+ existing_origin = _git_stdout(repo_root, ["remote", "get-url", "origin"])
10252
+ payload: dict[str, Any] = {
10253
+ "requested": True,
10254
+ "repo": github_repo,
10255
+ "remote_url": remote_url,
10256
+ "existing_origin": existing_origin,
10257
+ "action": "",
10258
+ "command": "",
10259
+ }
10260
+ if existing_origin:
10261
+ existing_repo = _github_repo_from_remote_url(existing_origin)
10262
+ if existing_repo and existing_repo != github_repo:
10263
+ raise RuntimeError(
10264
+ f"origin already points to `{existing_origin}`, not GitHub repo `{github_repo}`."
10265
+ )
10266
+ payload["action"] = "kept"
10267
+ payload["remote_url"] = existing_origin
10268
+ return payload
10269
+
10270
+ command = [
10271
+ "gh",
10272
+ "repo",
10273
+ "create",
10274
+ github_repo,
10275
+ "--private",
10276
+ "--source",
10277
+ str(repo_root),
10278
+ "--remote",
10279
+ "origin",
10280
+ ]
10281
+ payload["command"] = _command_preview(command)
10282
+ if dry_run:
10283
+ payload["action"] = "planned"
10284
+ return payload
10285
+
10286
+ gh_path = _tool_path("gh")
10287
+ if not gh_path:
10288
+ raise RuntimeError("creating a private GitHub remote requires the `gh` CLI on PATH.")
10289
+ _run_checked_process(
10290
+ [gh_path, *command[1:]],
10291
+ cwd=repo_root,
10292
+ context="private GitHub remote setup",
10293
+ )
10294
+ detected_origin = _git_stdout(repo_root, ["remote", "get-url", "origin"])
10295
+ payload["action"] = "created"
10296
+ payload["remote_url"] = detected_origin or remote_url
10297
+ payload["detected_origin_after"] = detected_origin
10298
+ return payload
10299
+
10300
+
10301
+ def _orp_workspace_cli_prefix() -> list[str]:
10302
+ override = str(os.environ.get("ORP_CLI", "") or "").strip()
10303
+ if override:
10304
+ return shlex.split(override)
10305
+ local_bin = _orp_repo_root() / "bin" / "orp.js"
10306
+ if local_bin.exists() and os.access(local_bin, os.X_OK):
10307
+ return [str(local_bin)]
10308
+ node_path = _tool_path("node")
10309
+ if local_bin.exists() and node_path:
10310
+ return [node_path, str(local_bin)]
10311
+ orp_path = _tool_path("orp")
10312
+ if orp_path:
10313
+ return [orp_path]
10314
+ return ["orp"]
10315
+
10316
+
10317
+ def _setup_workspace_startup_tracking(
10318
+ *,
10319
+ repo_root: Path,
10320
+ args: argparse.Namespace,
10321
+ default_branch: str,
10322
+ remote_url: str,
10323
+ codex_session_id: str,
10324
+ dry_run: bool,
10325
+ ) -> dict[str, Any]:
10326
+ workspace_name = str(getattr(args, "workspace_name", "") or "main").strip() or "main"
10327
+ command = [
10328
+ *_orp_workspace_cli_prefix(),
10329
+ "workspace",
10330
+ "add-tab",
10331
+ workspace_name,
10332
+ "--path",
10333
+ str(repo_root),
10334
+ ]
10335
+ title = str(getattr(args, "workspace_title", "") or "").strip()
10336
+ if title:
10337
+ command.extend(["--title", title])
10338
+ if remote_url:
10339
+ command.extend(["--remote-url", remote_url, "--remote-branch", default_branch])
10340
+ bootstrap_command = str(getattr(args, "workspace_bootstrap_command", "") or "").strip()
10341
+ if bootstrap_command:
10342
+ command.extend(["--bootstrap-command", bootstrap_command])
10343
+ if codex_session_id:
10344
+ command.extend(["--resume-tool", "codex", "--resume-session-id", codex_session_id])
10345
+ elif bool(getattr(args, "current_codex", False)) and os.environ.get("CODEX_THREAD_ID"):
10346
+ command.append("--current-codex")
10347
+ if bool(getattr(args, "workspace_append", False)):
10348
+ command.append("--append")
10349
+ command.append("--json")
10350
+ payload: dict[str, Any] = {
10351
+ "requested": True,
10352
+ "workspace": workspace_name,
10353
+ "path": str(repo_root),
10354
+ "remote_url": remote_url,
10355
+ "remote_branch": default_branch if remote_url else "",
10356
+ "codex_session_id": codex_session_id,
10357
+ "action": "planned" if dry_run else "",
10358
+ "command": _command_preview(command),
10359
+ }
10360
+ if dry_run:
10361
+ return payload
10362
+
10363
+ proc = _run_checked_process(command, cwd=repo_root, context="workspace main tracking")
10364
+ payload["action"] = "updated"
10365
+ try:
10366
+ payload["result"] = json.loads(proc.stdout)
10367
+ except Exception:
10368
+ payload["result"] = {"stdout": _truncate(proc.stdout.strip(), limit=600)}
10369
+ return payload
10370
+
10371
+
10372
+ def _clawdad_delegate_brief_template(
10373
+ *,
10374
+ repo_root: Path,
10375
+ remote_url: str,
10376
+ workspace_name: str,
10377
+ ) -> str:
10378
+ return (
10379
+ "# Clawdad Delegate Brief\n\n"
10380
+ f"- Project: `{repo_root.name}`\n"
10381
+ f"- Root: `{repo_root}`\n"
10382
+ f"- Remote: `{remote_url or '(local only)'}`\n"
10383
+ f"- ORP workspace: `{workspace_name}`\n\n"
10384
+ "## Startup Contract\n\n"
10385
+ "- This project is ORP-governed and registered for Clawdad/Codex delegation.\n"
10386
+ "- Run `orp status --json` and `orp hygiene --json` before long-running expansion.\n"
10387
+ "- Stop when hygiene reports `dirty_unclassified`; classify, refresh, canonicalize, or write a blocker.\n"
10388
+ "- Do not reset, checkout, or delete files merely to hide dirty state.\n"
10389
+ "- Keep canonical project state in repo files and keep process state in ORP/Clawdad ledgers.\n\n"
10390
+ "## First Checks\n\n"
10391
+ f"- `orp workspace tabs {workspace_name}`\n"
10392
+ "- `orp project show --json`\n"
10393
+ "- `orp hygiene --json`\n"
10394
+ "- `clawdad delegate <project>`\n\n"
10395
+ "## Delegate Posture\n\n"
10396
+ "- Prefer bounded, concrete tasks with a clear write scope.\n"
10397
+ "- Refresh project context after meaningful docs, manifest, roadmap, or agent-guidance changes.\n"
10398
+ "- Write a blocker instead of forcing progress when the repo state is ambiguous.\n"
10399
+ )
10400
+
10401
+
10402
+ def _setup_clawdad_startup(
10403
+ *,
10404
+ repo_root: Path,
10405
+ args: argparse.Namespace,
10406
+ remote_url: str,
10407
+ codex_session_id: str,
10408
+ dry_run: bool,
10409
+ ) -> dict[str, Any]:
10410
+ workspace_name = str(getattr(args, "workspace_name", "") or "main").strip() or "main"
10411
+ brief_path = repo_root / "orp" / "clawdad" / "DELEGATE_BRIEF.md"
10412
+ brief_action = _write_text_if_missing(
10413
+ brief_path,
10414
+ _clawdad_delegate_brief_template(
10415
+ repo_root=repo_root,
10416
+ remote_url=remote_url,
10417
+ workspace_name=workspace_name,
10418
+ ),
10419
+ )
10420
+ clawdad_path = _tool_path("clawdad")
10421
+ payload: dict[str, Any] = {
10422
+ "requested": True,
10423
+ "available": bool(clawdad_path),
10424
+ "brief_path": _path_for_state(brief_path, repo_root),
10425
+ "brief_action": brief_action,
10426
+ "action": "planned" if dry_run else "",
10427
+ "commands": [],
10428
+ "results": [],
10429
+ "ok": True,
10430
+ }
10431
+ description = str(getattr(args, "clawdad_description", "") or "").strip()
10432
+ if not description:
10433
+ description = f"ORP-governed project: {repo_root.name}"
10434
+ register_command = [
10435
+ "clawdad",
10436
+ "register",
10437
+ str(repo_root),
10438
+ "--provider",
10439
+ "codex",
10440
+ "--description",
10441
+ description,
10442
+ ]
10443
+ slug = str(getattr(args, "clawdad_slug", "") or "").strip()
10444
+ if slug:
10445
+ register_command.extend(["--slug", slug])
10446
+ commands = [
10447
+ register_command,
10448
+ ["clawdad", "delegate-set", str(repo_root), "--file", str(brief_path)],
10449
+ ]
10450
+ if codex_session_id:
10451
+ commands.append(["clawdad", "track-session", str(repo_root), codex_session_id])
10452
+ payload["commands"] = [_command_preview(command) for command in commands]
10453
+ if not clawdad_path:
10454
+ payload["action"] = "skipped_missing_clawdad"
10455
+ payload["ok"] = False
10456
+ return payload
10457
+ if dry_run:
10458
+ return payload
10459
+
10460
+ results: list[dict[str, Any]] = []
10461
+ for command in commands:
10462
+ result = _run_optional_process([clawdad_path, *command[1:]], cwd=repo_root)
10463
+ result["command"] = _command_preview(command)
10464
+ results.append(result)
10465
+ if not result.get("ok"):
10466
+ payload["ok"] = False
10467
+ break
10468
+ payload["results"] = results
10469
+ payload["action"] = "updated" if payload["ok"] else "blocked"
10470
+ return payload
10471
+
10472
+
10473
+ def _finish_init_startup(
10474
+ *,
10475
+ repo_root: Path,
10476
+ args: argparse.Namespace,
10477
+ default_branch: str,
10478
+ remote_context: dict[str, Any],
10479
+ startup: dict[str, Any],
10480
+ files: dict[str, dict[str, str]],
10481
+ notes: list[str],
10482
+ warnings: list[str],
10483
+ next_actions: list[str],
10484
+ ) -> dict[str, Any]:
10485
+ if not startup.get("enabled"):
10486
+ return startup
10487
+ dry_run = bool(startup.get("dry_run"))
10488
+ codex = _startup_codex_context(args, startup)
10489
+ actual_origin = _git_stdout(repo_root, ["remote", "get-url", "origin"])
10490
+ remote_url = actual_origin or str(remote_context.get("effective_remote_url", "") or "").strip()
10491
+
10492
+ if bool(getattr(args, "project_startup", False)) and not startup.get("github", {}).get("requested"):
10493
+ startup["next_actions"].append("pass --github-repo owner/repo to let project startup create or record a private GitHub remote")
10494
+
10495
+ if startup.get("workspace", {}).get("requested"):
10496
+ startup["workspace"] = _setup_workspace_startup_tracking(
10497
+ repo_root=repo_root,
10498
+ args=args,
10499
+ default_branch=default_branch,
10500
+ remote_url=remote_url,
10501
+ codex_session_id=codex["session_id"],
10502
+ dry_run=dry_run,
10503
+ )
10504
+ startup["commands"].append(startup["workspace"].get("command", ""))
10505
+
10506
+ if startup.get("clawdad", {}).get("requested"):
10507
+ startup["clawdad"] = _setup_clawdad_startup(
10508
+ repo_root=repo_root,
10509
+ args=args,
10510
+ remote_url=remote_url,
10511
+ codex_session_id=codex["session_id"],
10512
+ dry_run=dry_run,
10513
+ )
10514
+ files["clawdad_delegate_brief"] = {
10515
+ "path": str(startup["clawdad"].get("brief_path", "")),
10516
+ "action": str(startup["clawdad"].get("brief_action", "")),
10517
+ }
10518
+ startup["commands"].extend(startup["clawdad"].get("commands", []))
10519
+ if startup["clawdad"].get("action") == "skipped_missing_clawdad":
10520
+ startup["warnings"].append("Clawdad is not installed on PATH; ORP wrote the delegate brief but did not register the project.")
10521
+ startup["next_actions"].append("install/run `clawdad init`, then rerun `orp init --with-clawdad`")
10522
+ elif startup["clawdad"].get("ok") is False:
10523
+ startup["warnings"].append("Clawdad registration did not complete; inspect startup.clawdad.results for the command output.")
10524
+
10525
+ if bool(getattr(args, "project_startup", False)) and not _tool_path("clawdad"):
10526
+ startup["notes"] = [
10527
+ "project startup did not enable Clawdad delegation because `clawdad` was not found on PATH."
10528
+ ]
10529
+ else:
10530
+ startup["notes"] = []
10531
+
10532
+ startup["warnings"] = _unique_strings([str(item) for item in startup.get("warnings", []) if str(item).strip()])
10533
+ startup["next_actions"] = _unique_strings(
10534
+ [str(item) for item in startup.get("next_actions", []) if str(item).strip()]
10535
+ )
10536
+ warnings.extend(startup["warnings"])
10537
+ next_actions.extend(startup["next_actions"])
10538
+ notes.extend(startup.get("notes", []))
10539
+
10540
+ startup["ok"] = bool(startup.get("ok", True)) and bool(startup.get("workspace", {}).get("action") != "blocked")
10541
+ if startup.get("clawdad", {}).get("requested") and startup.get("clawdad", {}).get("ok") is False:
10542
+ startup["ok"] = False if bool(getattr(args, "with_clawdad", False)) else bool(startup["ok"])
10543
+ return startup
10544
+
10545
+
10049
10546
  def _project_context_path(repo_root: Path) -> Path:
10050
10547
  return repo_root / "orp" / "project.json"
10051
10548
 
@@ -12542,6 +13039,10 @@ def _home_payload(repo_root: Path, config_arg: str) -> dict[str, Any]:
12542
13039
  "label": "Classify dirty worktree paths before long agent expansion",
12543
13040
  "command": "orp hygiene --json",
12544
13041
  },
13042
+ {
13043
+ "label": "Bootstrap a new project with private GitHub, workspace main, and Clawdad when installed",
13044
+ "command": "orp init --project-startup --github-repo owner/repo --current-codex",
13045
+ },
12545
13046
  {
12546
13047
  "label": "Inspect the saved service and data connections for this user",
12547
13048
  "command": "orp connections list",
@@ -13561,6 +14062,22 @@ def cmd_init(args: argparse.Namespace) -> int:
13561
14062
  if not git_was_present:
13562
14063
  git_init_result = _git_init_repo(repo_root, default_branch)
13563
14064
 
14065
+ startup = _init_startup_context(args)
14066
+ if startup.get("enabled") and startup.get("github", {}).get("requested"):
14067
+ _effective_remote_context(
14068
+ detected_remote_url="",
14069
+ detected_github_repo="",
14070
+ remote_url_arg=str(getattr(args, "remote_url", "") or ""),
14071
+ github_repo_arg=str(getattr(args, "github_repo", "") or ""),
14072
+ )
14073
+ startup["github"] = _setup_private_github_startup_remote(
14074
+ repo_root=repo_root,
14075
+ github_repo_raw=str(getattr(args, "github_repo", "") or getattr(args, "remote_url", "") or ""),
14076
+ dry_run=bool(startup.get("dry_run")),
14077
+ )
14078
+ if startup["github"].get("command"):
14079
+ startup["commands"].append(str(startup["github"]["command"]))
14080
+
13564
14081
  _ensure_dirs(repo_root)
13565
14082
  config_path = repo_root / args.config
13566
14083
  config_action = "kept"
@@ -13746,8 +14263,34 @@ def cmd_init(args: argparse.Namespace) -> int:
13746
14263
  "action": project_context_action,
13747
14264
  }
13748
14265
 
14266
+ if startup.get("enabled"):
14267
+ startup = _finish_init_startup(
14268
+ repo_root=repo_root,
14269
+ args=args,
14270
+ default_branch=default_branch,
14271
+ remote_context=remote_context,
14272
+ startup=startup,
14273
+ files=files,
14274
+ notes=notes,
14275
+ warnings=warnings,
14276
+ next_actions=next_actions,
14277
+ )
14278
+ state = _read_json(state_path) if state_path.exists() else _default_state_payload()
14279
+ if not isinstance(state, dict):
14280
+ state = _default_state_payload()
14281
+ state["startup"] = {
14282
+ "updated_at_utc": _now_utc(),
14283
+ "enabled": bool(startup.get("enabled")),
14284
+ "project_startup": bool(startup.get("project_startup")),
14285
+ "github": startup.get("github", {}),
14286
+ "workspace": startup.get("workspace", {}),
14287
+ "clawdad": startup.get("clawdad", {}),
14288
+ "codex": startup.get("codex", {}),
14289
+ }
14290
+ _write_json(state_path, state)
14291
+
13749
14292
  result = {
13750
- "ok": True,
14293
+ "ok": bool(startup.get("ok", True)),
13751
14294
  "config_action": config_action,
13752
14295
  "config_path": str(config_path),
13753
14296
  "runtime_root": str(repo_root / "orp"),
@@ -13779,6 +14322,8 @@ def cmd_init(args: argparse.Namespace) -> int:
13779
14322
  "notes": notes,
13780
14323
  "next_actions": next_actions,
13781
14324
  }
14325
+ if startup.get("enabled"):
14326
+ result["startup"] = startup
13782
14327
  if args.json_output:
13783
14328
  _print_json(result)
13784
14329
  else:
@@ -13792,6 +14337,11 @@ def cmd_init(args: argparse.Namespace) -> int:
13792
14337
  print("synced AGENTS.md and CLAUDE.md with ORP-managed blocks")
13793
14338
  print(f"project_context={_path_for_state(_project_context_path(repo_root), repo_root)}")
13794
14339
  print(f"hygiene_policy={_path_for_state(hygiene_policy_path, repo_root)}")
14340
+ if startup.get("enabled"):
14341
+ print(f"startup.ok={'true' if startup.get('ok') else 'false'}")
14342
+ print(f"startup.github={startup.get('github', {}).get('action', 'not_requested')}")
14343
+ print(f"startup.workspace={startup.get('workspace', {}).get('action', 'not_requested')}")
14344
+ print(f"startup.clawdad={startup.get('clawdad', {}).get('action', 'not_requested')}")
13795
14345
  print(
13796
14346
  "git_state="
13797
14347
  + ",".join(
@@ -17166,6 +17716,7 @@ def _research_staged_deep_think_profile(profile_id: str = "deep-think-web-think-
17166
17716
  "web_search": True,
17167
17717
  "web_search_tool": "web_search_preview",
17168
17718
  "background": False,
17719
+ "spend_reserve_usd": 1.5,
17169
17720
  "max_tool_calls": 40,
17170
17721
  "max_output_tokens": 12000,
17171
17722
  },
@@ -17199,6 +17750,7 @@ def _research_staged_deep_think_profile(profile_id: str = "deep-think-web-think-
17199
17750
  "secret_alias": "openai-primary",
17200
17751
  "reasoning_effort": "high",
17201
17752
  "text_verbosity": "medium",
17753
+ "spend_reserve_usd": 0.5,
17202
17754
  "max_output_tokens": 4200,
17203
17755
  },
17204
17756
  {
@@ -17235,6 +17787,7 @@ def _research_staged_deep_think_profile(profile_id: str = "deep-think-web-think-
17235
17787
  "web_search_tool": "web_search",
17236
17788
  "search_context_size": "high",
17237
17789
  "external_web_access": True,
17790
+ "spend_reserve_usd": 1.0,
17238
17791
  "max_tool_calls": 8,
17239
17792
  "max_output_tokens": 4200,
17240
17793
  },
@@ -17267,6 +17820,7 @@ def _research_staged_deep_think_profile(profile_id: str = "deep-think-web-think-
17267
17820
  "secret_alias": "openai-primary",
17268
17821
  "reasoning_effort": "high",
17269
17822
  "text_verbosity": "medium",
17823
+ "spend_reserve_usd": 0.5,
17270
17824
  "max_output_tokens": 5000,
17271
17825
  },
17272
17826
  {
@@ -17301,6 +17855,7 @@ def _research_staged_deep_think_profile(profile_id: str = "deep-think-web-think-
17301
17855
  "web_search": True,
17302
17856
  "web_search_tool": "web_search_preview",
17303
17857
  "background": False,
17858
+ "spend_reserve_usd": 1.5,
17304
17859
  "max_tool_calls": 40,
17305
17860
  "max_output_tokens": 12000,
17306
17861
  },
@@ -17378,6 +17933,7 @@ def _research_default_profile(profile_id: str = "openai-council") -> dict[str, A
17378
17933
  "secret_alias": "openai-primary",
17379
17934
  "reasoning_effort": "high",
17380
17935
  "text_verbosity": "medium",
17936
+ "spend_reserve_usd": 0.5,
17381
17937
  "max_output_tokens": 4200,
17382
17938
  },
17383
17939
  {
@@ -17396,6 +17952,7 @@ def _research_default_profile(profile_id: str = "openai-council") -> dict[str, A
17396
17952
  "web_search_tool": "web_search",
17397
17953
  "search_context_size": "high",
17398
17954
  "external_web_access": True,
17955
+ "spend_reserve_usd": 1.0,
17399
17956
  "max_tool_calls": 8,
17400
17957
  "max_output_tokens": 3600,
17401
17958
  },
@@ -17413,6 +17970,7 @@ def _research_default_profile(profile_id: str = "openai-council") -> dict[str, A
17413
17970
  "web_search": True,
17414
17971
  "web_search_tool": "web_search_preview",
17415
17972
  "background": True,
17973
+ "spend_reserve_usd": 3.5,
17416
17974
  "max_tool_calls": 40,
17417
17975
  "max_output_tokens": 12000,
17418
17976
  },
@@ -17700,6 +18258,312 @@ def _research_text_from_payload(payload: Any) -> str:
17700
18258
  return ""
17701
18259
 
17702
18260
 
18261
+ def _as_positive_float(value: Any, *, field_name: str) -> float:
18262
+ try:
18263
+ parsed = float(value)
18264
+ except Exception as exc:
18265
+ raise RuntimeError(f"{field_name} must be a positive number.") from exc
18266
+ if parsed <= 0:
18267
+ raise RuntimeError(f"{field_name} must be greater than 0.")
18268
+ return parsed
18269
+
18270
+
18271
+ def _normalize_secret_spend_policy(raw_policy: Any) -> dict[str, Any]:
18272
+ if not isinstance(raw_policy, dict):
18273
+ return {}
18274
+ raw_daily_cap = raw_policy.get("daily_cap_usd", raw_policy.get("dailyCapUsd"))
18275
+ if raw_daily_cap in (None, ""):
18276
+ return {}
18277
+ try:
18278
+ daily_cap_usd = float(raw_daily_cap)
18279
+ except Exception:
18280
+ return {}
18281
+ if daily_cap_usd <= 0:
18282
+ return {}
18283
+
18284
+ raw_dashboard = raw_policy.get("dashboard_limit", raw_policy.get("dashboardLimit"))
18285
+ dashboard_limit = dict(raw_dashboard) if isinstance(raw_dashboard, dict) else {}
18286
+ status = str(
18287
+ dashboard_limit.get(
18288
+ "status",
18289
+ raw_policy.get("dashboard_status", raw_policy.get("dashboardStatus", "")),
18290
+ )
18291
+ or ""
18292
+ ).strip()
18293
+ project_id = str(
18294
+ dashboard_limit.get(
18295
+ "project_id",
18296
+ dashboard_limit.get("projectId", raw_policy.get("dashboard_project_id", raw_policy.get("dashboardProjectId", ""))),
18297
+ )
18298
+ or ""
18299
+ ).strip()
18300
+ dashboard_url = str(
18301
+ dashboard_limit.get(
18302
+ "dashboard_url",
18303
+ dashboard_limit.get("dashboardUrl", raw_policy.get("dashboard_url", raw_policy.get("dashboardUrl", ""))),
18304
+ )
18305
+ or ""
18306
+ ).strip()
18307
+ normalized_dashboard: dict[str, Any] = {
18308
+ "provider": str(dashboard_limit.get("provider", raw_policy.get("provider", "openai")) or "openai").strip()
18309
+ or "openai",
18310
+ "status": status or "unconfirmed",
18311
+ }
18312
+ if project_id:
18313
+ normalized_dashboard["project_id"] = project_id
18314
+ if dashboard_url:
18315
+ normalized_dashboard["dashboard_url"] = dashboard_url
18316
+
18317
+ return {
18318
+ "schema_version": str(raw_policy.get("schema_version", raw_policy.get("schemaVersion", SECRET_SPEND_POLICY_SCHEMA_VERSION))).strip()
18319
+ or SECRET_SPEND_POLICY_SCHEMA_VERSION,
18320
+ "daily_cap_usd": round(daily_cap_usd, 6),
18321
+ "currency": str(raw_policy.get("currency", "USD") or "USD").strip().upper() or "USD",
18322
+ "scope": str(raw_policy.get("scope", "provider_project_key") or "provider_project_key").strip()
18323
+ or "provider_project_key",
18324
+ "enforcement": str(raw_policy.get("enforcement", "local_preflight_reservation") or "local_preflight_reservation").strip()
18325
+ or "local_preflight_reservation",
18326
+ "dashboard_limit": normalized_dashboard,
18327
+ "ledger_path": str(raw_policy.get("ledger_path", raw_policy.get("ledgerPath", str(_research_spend_ledger_path()))) or str(_research_spend_ledger_path())),
18328
+ }
18329
+
18330
+
18331
+ def _secret_spend_policy_payload(policy: dict[str, Any]) -> dict[str, Any]:
18332
+ normalized = _normalize_secret_spend_policy(policy)
18333
+ if not normalized:
18334
+ return {}
18335
+ dashboard_limit = normalized.get("dashboard_limit") if isinstance(normalized.get("dashboard_limit"), dict) else {}
18336
+ payload: dict[str, Any] = {
18337
+ "schemaVersion": str(normalized.get("schema_version", SECRET_SPEND_POLICY_SCHEMA_VERSION)).strip()
18338
+ or SECRET_SPEND_POLICY_SCHEMA_VERSION,
18339
+ "dailyCapUsd": normalized["daily_cap_usd"],
18340
+ "currency": str(normalized.get("currency", "USD")).strip() or "USD",
18341
+ "scope": str(normalized.get("scope", "provider_project_key")).strip() or "provider_project_key",
18342
+ "enforcement": str(normalized.get("enforcement", "local_preflight_reservation")).strip()
18343
+ or "local_preflight_reservation",
18344
+ "dashboardLimit": {
18345
+ "provider": str(dashboard_limit.get("provider", "openai")).strip() or "openai",
18346
+ "status": str(dashboard_limit.get("status", "unconfirmed")).strip() or "unconfirmed",
18347
+ },
18348
+ "ledgerPath": str(normalized.get("ledger_path", str(_research_spend_ledger_path()))).strip()
18349
+ or str(_research_spend_ledger_path()),
18350
+ }
18351
+ project_id = str(dashboard_limit.get("project_id", "")).strip()
18352
+ dashboard_url = str(dashboard_limit.get("dashboard_url", "")).strip()
18353
+ if project_id:
18354
+ payload["dashboardLimit"]["projectId"] = project_id
18355
+ if dashboard_url:
18356
+ payload["dashboardLimit"]["dashboardUrl"] = dashboard_url
18357
+ return payload
18358
+
18359
+
18360
+ def _secret_spend_policy_from_args(
18361
+ args: argparse.Namespace,
18362
+ existing_entry: dict[str, Any] | None = None,
18363
+ ) -> dict[str, Any]:
18364
+ existing_policy = _normalize_secret_spend_policy(
18365
+ existing_entry.get("spend_policy", {}) if isinstance(existing_entry, dict) else {}
18366
+ )
18367
+ daily_cap_arg = getattr(args, "daily_spend_cap_usd", None)
18368
+ dashboard_status = str(getattr(args, "dashboard_spend_cap_status", "") or "").strip()
18369
+ dashboard_project_id = str(getattr(args, "dashboard_project_id", "") or "").strip()
18370
+ dashboard_url = str(getattr(args, "dashboard_url", "") or "").strip()
18371
+ if daily_cap_arg in (None, "") and not dashboard_status and not dashboard_project_id and not dashboard_url:
18372
+ return existing_policy
18373
+
18374
+ if daily_cap_arg in (None, ""):
18375
+ if not existing_policy:
18376
+ raise RuntimeError("--daily-spend-cap-usd is required when creating a spend policy.")
18377
+ daily_cap_usd = float(existing_policy["daily_cap_usd"])
18378
+ else:
18379
+ daily_cap_usd = _as_positive_float(daily_cap_arg, field_name="--daily-spend-cap-usd")
18380
+
18381
+ existing_dashboard = (
18382
+ existing_policy.get("dashboard_limit")
18383
+ if isinstance(existing_policy.get("dashboard_limit"), dict)
18384
+ else {}
18385
+ )
18386
+ dashboard_limit = dict(existing_dashboard)
18387
+ if dashboard_status:
18388
+ dashboard_limit["status"] = dashboard_status
18389
+ elif "status" not in dashboard_limit:
18390
+ dashboard_limit["status"] = "unconfirmed"
18391
+ if dashboard_project_id:
18392
+ dashboard_limit["project_id"] = dashboard_project_id
18393
+ if dashboard_url:
18394
+ dashboard_limit["dashboard_url"] = dashboard_url
18395
+ dashboard_limit["provider"] = str(dashboard_limit.get("provider", getattr(args, "provider", "openai")) or "openai").strip() or "openai"
18396
+
18397
+ return _normalize_secret_spend_policy(
18398
+ {
18399
+ **existing_policy,
18400
+ "schema_version": SECRET_SPEND_POLICY_SCHEMA_VERSION,
18401
+ "daily_cap_usd": daily_cap_usd,
18402
+ "currency": "USD",
18403
+ "scope": "provider_project_key",
18404
+ "enforcement": "local_preflight_reservation",
18405
+ "dashboard_limit": dashboard_limit,
18406
+ "ledger_path": str(_research_spend_ledger_path()),
18407
+ }
18408
+ )
18409
+
18410
+
18411
+ def _research_lane_spend_reserve_usd(lane: dict[str, Any]) -> float:
18412
+ if "spend_reserve_usd" in lane:
18413
+ try:
18414
+ reserve = float(lane.get("spend_reserve_usd", 0) or 0)
18415
+ except Exception:
18416
+ reserve = 0.0
18417
+ return max(0.0, round(reserve, 6))
18418
+ model = str(lane.get("model", "") or "").strip().lower()
18419
+ if "deep-research" in model:
18420
+ return 3.5
18421
+ if bool(lane.get("web_search", False)) or lane.get("tools"):
18422
+ return 1.0
18423
+ effort = str(lane.get("reasoning_effort", "") or "").strip().lower()
18424
+ if effort in {"high", "xhigh"}:
18425
+ return 0.5
18426
+ return 0.25
18427
+
18428
+
18429
+ def _research_spend_policy_entry_for_lane(lane: dict[str, Any]) -> tuple[dict[str, Any] | None, str]:
18430
+ secret_alias = str(lane.get("secret_alias", "") or "").strip()
18431
+ provider = str(lane.get("provider", "") or "").strip()
18432
+ if not secret_alias and not provider:
18433
+ return None, "no secret alias or provider configured"
18434
+ try:
18435
+ return (
18436
+ _select_keychain_entry(
18437
+ secret_ref=secret_alias,
18438
+ provider=provider,
18439
+ world_id="",
18440
+ idea_id="",
18441
+ ),
18442
+ "",
18443
+ )
18444
+ except Exception as exc:
18445
+ return None, str(exc)
18446
+
18447
+
18448
+ def _research_spend_ledger_today_total(
18449
+ *,
18450
+ date_utc: str,
18451
+ provider: str,
18452
+ secret_alias: str,
18453
+ ) -> float:
18454
+ ledger = _load_research_spend_ledger()
18455
+ total = 0.0
18456
+ for row in ledger.get("records", []):
18457
+ if not isinstance(row, dict):
18458
+ continue
18459
+ if str(row.get("date_utc", "")).strip() != date_utc:
18460
+ continue
18461
+ if provider and str(row.get("provider", "")).strip() != provider:
18462
+ continue
18463
+ if secret_alias and str(row.get("secret_alias", "")).strip() != secret_alias:
18464
+ continue
18465
+ event = str(row.get("event", "")).strip()
18466
+ if event != "reserved":
18467
+ continue
18468
+ try:
18469
+ total += float(row.get("amount_usd", row.get("reserve_usd", 0)) or 0)
18470
+ except Exception:
18471
+ continue
18472
+ return round(total, 6)
18473
+
18474
+
18475
+ def _research_openai_spend_preflight(
18476
+ lane: dict[str, Any],
18477
+ *,
18478
+ secret_source: str,
18479
+ ) -> dict[str, Any]:
18480
+ provider = str(lane.get("provider", "") or "").strip()
18481
+ secret_alias = str(lane.get("secret_alias", "") or "").strip()
18482
+ reserve_usd = _research_lane_spend_reserve_usd(lane)
18483
+ entry, entry_issue = _research_spend_policy_entry_for_lane(lane)
18484
+ policy = _normalize_secret_spend_policy(entry.get("spend_policy", {}) if isinstance(entry, dict) else {})
18485
+ date_utc = dt.datetime.now(dt.timezone.utc).date().isoformat()
18486
+ base = {
18487
+ "schema_version": RESEARCH_SPEND_LEDGER_SCHEMA_VERSION,
18488
+ "provider": provider,
18489
+ "secret_alias": secret_alias,
18490
+ "secret_source": secret_source,
18491
+ "date_utc": date_utc,
18492
+ "reserve_usd": reserve_usd,
18493
+ "ledger_path": str(_research_spend_ledger_path()),
18494
+ }
18495
+ if not policy:
18496
+ return {
18497
+ **base,
18498
+ "allowed": True,
18499
+ "policy_source": "",
18500
+ "reason": entry_issue or "no spend policy configured for this local keychain entry",
18501
+ }
18502
+
18503
+ reserved_today = _research_spend_ledger_today_total(
18504
+ date_utc=date_utc,
18505
+ provider=provider,
18506
+ secret_alias=secret_alias,
18507
+ )
18508
+ daily_cap_usd = float(policy["daily_cap_usd"])
18509
+ remaining_before = round(max(0.0, daily_cap_usd - reserved_today), 6)
18510
+ remaining_after = round(daily_cap_usd - reserved_today - reserve_usd, 6)
18511
+ allowed = remaining_after >= -0.000001
18512
+ dashboard_limit = policy.get("dashboard_limit") if isinstance(policy.get("dashboard_limit"), dict) else {}
18513
+ return {
18514
+ **base,
18515
+ "allowed": allowed,
18516
+ "policy_source": "keychain",
18517
+ "daily_cap_usd": round(daily_cap_usd, 6),
18518
+ "currency": str(policy.get("currency", "USD")).strip() or "USD",
18519
+ "reserved_today_usd": reserved_today,
18520
+ "remaining_before_reserve_usd": remaining_before,
18521
+ "remaining_after_reserve_usd": remaining_after,
18522
+ "dashboard_limit": dict(dashboard_limit),
18523
+ "reason": "within daily spend cap" if allowed else "daily spend cap would be exceeded",
18524
+ }
18525
+
18526
+
18527
+ def _research_append_spend_ledger_record(
18528
+ lane: dict[str, Any],
18529
+ spend_preflight: dict[str, Any],
18530
+ *,
18531
+ event: str,
18532
+ status: str = "",
18533
+ provider_response_id: str = "",
18534
+ usage: dict[str, Any] | None = None,
18535
+ ) -> dict[str, Any]:
18536
+ if spend_preflight.get("policy_source") != "keychain":
18537
+ return {}
18538
+ amount_usd = round(float(spend_preflight.get("reserve_usd", 0) or 0), 6) if event == "reserved" else 0.0
18539
+ record = {
18540
+ "id": f"spend-{uuid.uuid4().hex[:12]}",
18541
+ "schema_version": RESEARCH_SPEND_LEDGER_SCHEMA_VERSION,
18542
+ "recorded_at_utc": _now_utc(),
18543
+ "date_utc": str(spend_preflight.get("date_utc", "")).strip(),
18544
+ "event": event,
18545
+ "provider": str(spend_preflight.get("provider", lane.get("provider", ""))).strip(),
18546
+ "secret_alias": str(spend_preflight.get("secret_alias", lane.get("secret_alias", ""))).strip(),
18547
+ "lane_id": str(lane.get("lane_id", "")).strip(),
18548
+ "call_moment": str(lane.get("call_moment", lane.get("lane_id", ""))).strip(),
18549
+ "model": str(lane.get("model", "")).strip(),
18550
+ "amount_usd": amount_usd,
18551
+ "reserve_usd": round(float(spend_preflight.get("reserve_usd", 0) or 0), 6),
18552
+ "currency": str(spend_preflight.get("currency", "USD")).strip() or "USD",
18553
+ "daily_cap_usd": spend_preflight.get("daily_cap_usd"),
18554
+ "status": status,
18555
+ "provider_response_id": provider_response_id,
18556
+ }
18557
+ if isinstance(usage, dict) and usage:
18558
+ record["usage"] = usage
18559
+ ledger = _load_research_spend_ledger()
18560
+ records = ledger.get("records") if isinstance(ledger.get("records"), list) else []
18561
+ records.append(record)
18562
+ ledger["records"] = records
18563
+ _save_research_spend_ledger(ledger)
18564
+ return record
18565
+
18566
+
17703
18567
  def _research_lane_api_call_plan(
17704
18568
  lane: dict[str, Any],
17705
18569
  *,
@@ -17709,12 +18573,13 @@ def _research_lane_api_call_plan(
17709
18573
  reason: str = "",
17710
18574
  request_body_keys: Sequence[str] | None = None,
17711
18575
  tools: Sequence[str] | None = None,
18576
+ spend_preflight: dict[str, Any] | None = None,
17712
18577
  ) -> dict[str, Any]:
17713
18578
  adapter = str(lane.get("adapter", "")).strip()
17714
18579
  provider = str(lane.get("provider", "")).strip()
17715
18580
  env_var = str(lane.get("env_var", "")).strip()
17716
18581
  secret_alias = str(lane.get("secret_alias", "")).strip()
17717
- return {
18582
+ plan = {
17718
18583
  "call_moment": str(lane.get("call_moment", lane.get("lane_id", ""))).strip(),
17719
18584
  "calls_api": adapter in {"openai_responses", "anthropic_messages", "xai_chat_completions", "chimera_cli"},
17720
18585
  "called": bool(called),
@@ -17732,6 +18597,9 @@ def _research_lane_api_call_plan(
17732
18597
  "tools": [str(row) for row in tools] if tools else [],
17733
18598
  "reason": reason,
17734
18599
  }
18600
+ if spend_preflight is not None:
18601
+ plan["spend_preflight"] = spend_preflight
18602
+ return plan
17735
18603
 
17736
18604
 
17737
18605
  def _research_fixture_lane_result(
@@ -18101,6 +18969,49 @@ def _research_run_openai_lane(
18101
18969
  for row in body.get("tools", [])
18102
18970
  if isinstance(row, dict) and str(row.get("type", "")).strip()
18103
18971
  ]
18972
+ spend_preflight = _research_openai_spend_preflight(lane, secret_source=secret_source)
18973
+ if not bool(spend_preflight.get("allowed", True)):
18974
+ finished_at_utc = _now_utc()
18975
+ return {
18976
+ "schema_version": RESEARCH_RUN_SCHEMA_VERSION,
18977
+ "lane_id": lane["lane_id"],
18978
+ "label": lane.get("label", lane["lane_id"]),
18979
+ "provider": lane.get("provider", ""),
18980
+ "model": lane.get("model", ""),
18981
+ "adapter": "openai_responses",
18982
+ "call_moment": lane.get("call_moment", lane["lane_id"]),
18983
+ "api_call": _research_lane_api_call_plan(
18984
+ lane,
18985
+ execute=True,
18986
+ called=False,
18987
+ secret_source=secret_source,
18988
+ reason=str(spend_preflight.get("reason", "spend preflight blocked provider call")),
18989
+ request_body_keys=body.keys(),
18990
+ tools=tool_types,
18991
+ spend_preflight=spend_preflight,
18992
+ ),
18993
+ "status": "skipped",
18994
+ "started_at_utc": started_at_utc,
18995
+ "finished_at_utc": finished_at_utc,
18996
+ "duration_ms": _duration_ms(started_at_utc, finished_at_utc),
18997
+ "text": "",
18998
+ "spend_preflight": spend_preflight,
18999
+ "notes": [
19000
+ str(spend_preflight.get("reason", "Spend preflight blocked provider call.")),
19001
+ f"Secret supplied from {secret_source}; secret value was not persisted.",
19002
+ ],
19003
+ }
19004
+ reservation = _research_append_spend_ledger_record(
19005
+ lane,
19006
+ spend_preflight,
19007
+ event="reserved",
19008
+ status="pending",
19009
+ )
19010
+ if reservation:
19011
+ spend_preflight = {
19012
+ **spend_preflight,
19013
+ "reservation_id": str(reservation.get("id", "")).strip(),
19014
+ }
18104
19015
  api_call = _research_lane_api_call_plan(
18105
19016
  lane,
18106
19017
  execute=True,
@@ -18108,6 +19019,7 @@ def _research_run_openai_lane(
18108
19019
  secret_source=secret_source,
18109
19020
  request_body_keys=body.keys(),
18110
19021
  tools=tool_types,
19022
+ spend_preflight=spend_preflight,
18111
19023
  )
18112
19024
 
18113
19025
  request = urlrequest.Request(
@@ -18172,6 +19084,14 @@ def _research_run_openai_lane(
18172
19084
  status = "complete" if response_status == "completed" and text else response_status or "complete"
18173
19085
  if status == "in_progress":
18174
19086
  text = text or "OpenAI deep research started in background mode; poll the response id outside ORP for completion."
19087
+ _research_append_spend_ledger_record(
19088
+ lane,
19089
+ spend_preflight,
19090
+ event="usage",
19091
+ status=status,
19092
+ provider_response_id=str(response_payload.get("id", "")).strip(),
19093
+ usage=response_payload.get("usage") if isinstance(response_payload.get("usage"), dict) else None,
19094
+ )
18175
19095
  return {
18176
19096
  "schema_version": RESEARCH_RUN_SCHEMA_VERSION,
18177
19097
  "lane_id": lane["lane_id"],
@@ -18181,6 +19101,7 @@ def _research_run_openai_lane(
18181
19101
  "adapter": "openai_responses",
18182
19102
  "call_moment": lane.get("call_moment", lane["lane_id"]),
18183
19103
  "api_call": api_call,
19104
+ "spend_preflight": spend_preflight,
18184
19105
  "status": status,
18185
19106
  "started_at_utc": started_at_utc,
18186
19107
  "finished_at_utc": finished_at_utc,
@@ -22632,6 +23553,12 @@ def _print_secret_human(
22632
23553
  source: str = "",
22633
23554
  ) -> None:
22634
23555
  bindings = _secret_bindings(secret)
23556
+ spend_policy = _normalize_secret_spend_policy(secret.get("spendPolicy", secret.get("spend_policy", {})))
23557
+ dashboard_limit = (
23558
+ spend_policy.get("dashboard_limit")
23559
+ if isinstance(spend_policy.get("dashboard_limit"), dict)
23560
+ else {}
23561
+ )
22635
23562
  _print_pairs(
22636
23563
  [
22637
23564
  ("secret.id", str(secret.get("id", "")).strip()),
@@ -22644,6 +23571,9 @@ def _print_secret_human(
22644
23571
  ("secret.preview", str(secret.get("valuePreview", "")).strip()),
22645
23572
  ("secret.version", str(secret.get("valueVersion", "")).strip()),
22646
23573
  ("secret.status", str(secret.get("status", "")).strip()),
23574
+ ("secret.spend_policy.daily_cap_usd", str(spend_policy.get("daily_cap_usd", ""))),
23575
+ ("secret.spend_policy.enforcement", str(spend_policy.get("enforcement", ""))),
23576
+ ("secret.spend_policy.dashboard_status", str(dashboard_limit.get("status", ""))),
22647
23577
  ("secret.binding_count", len(bindings)),
22648
23578
  ("secret.last_used_at", str(secret.get("lastUsedAt", "")).strip()),
22649
23579
  ("secret.rotated_at", str(secret.get("rotatedAt", "")).strip()),
@@ -22754,7 +23684,7 @@ def _build_keychain_registry_entry(
22754
23684
  bindings = [_normalize_secret_binding_summary(row) for row in _secret_bindings(secret)]
22755
23685
  if binding:
22756
23686
  bindings = _merge_secret_binding_summaries(bindings, [binding])
22757
- return {
23687
+ entry = {
22758
23688
  "secret_id": str(secret.get("id", "")).strip(),
22759
23689
  "alias": str(secret.get("alias", "")).strip(),
22760
23690
  "label": str(secret.get("label", "")).strip(),
@@ -22771,11 +23701,15 @@ def _build_keychain_registry_entry(
22771
23701
  "bindings": bindings,
22772
23702
  "last_synced_at_utc": _now_utc(),
22773
23703
  }
23704
+ spend_policy = _normalize_secret_spend_policy(secret.get("spendPolicy", secret.get("spend_policy", {})))
23705
+ if spend_policy:
23706
+ entry["spend_policy"] = spend_policy
23707
+ return entry
22774
23708
 
22775
23709
 
22776
23710
  def _secret_payload_from_keychain_entry(entry: dict[str, Any]) -> dict[str, Any]:
22777
23711
  bindings = entry.get("bindings") if isinstance(entry.get("bindings"), list) else []
22778
- return {
23712
+ payload = {
22779
23713
  "id": str(entry.get("secret_id", "")).strip(),
22780
23714
  "alias": str(entry.get("alias", "")).strip(),
22781
23715
  "label": str(entry.get("label", "")).strip(),
@@ -22792,6 +23726,12 @@ def _secret_payload_from_keychain_entry(entry: dict[str, Any]) -> dict[str, Any]
22792
23726
  if isinstance(row, dict)
22793
23727
  ],
22794
23728
  }
23729
+ spend_policy = _secret_spend_policy_payload(
23730
+ entry.get("spend_policy", {}) if isinstance(entry.get("spend_policy"), dict) else {}
23731
+ )
23732
+ if spend_policy:
23733
+ payload["spendPolicy"] = spend_policy
23734
+ return payload
22795
23735
 
22796
23736
 
22797
23737
  def _upsert_keychain_secret_registry_entry(entry: dict[str, Any]) -> dict[str, Any]:
@@ -23005,8 +23945,9 @@ def _build_local_keychain_secret_from_args(args: argparse.Namespace, existing_en
23005
23945
  kind = str(getattr(args, "kind", "api_key") or "api_key").strip() or "api_key"
23006
23946
  username = getattr(args, "username", None)
23007
23947
  env_var_name = getattr(args, "env_var_name", None)
23948
+ spend_policy = _secret_spend_policy_from_args(args, existing_entry)
23008
23949
  now = _now_utc()
23009
- return {
23950
+ secret = {
23010
23951
  "id": str(existing_entry.get("secret_id", "") if existing_entry else "").strip() or f"local-{uuid.uuid4().hex[:12]}",
23011
23952
  "alias": alias,
23012
23953
  "label": label,
@@ -23026,6 +23967,9 @@ def _build_local_keychain_secret_from_args(args: argparse.Namespace, existing_en
23026
23967
  "rotatedAt": now,
23027
23968
  "updatedAt": now,
23028
23969
  }
23970
+ if spend_policy:
23971
+ secret["spendPolicy"] = spend_policy
23972
+ return secret
23029
23973
 
23030
23974
 
23031
23975
  def _try_get_secret_by_ref(args: argparse.Namespace, secret_ref: str) -> dict[str, Any] | None:
@@ -24868,6 +25812,47 @@ def cmd_secrets_keychain_add(args: argparse.Namespace) -> int:
24868
25812
  return 0
24869
25813
 
24870
25814
 
25815
+ def cmd_secrets_keychain_spend_policy(args: argparse.Namespace) -> int:
25816
+ secret_ref = str(getattr(args, "secret_ref", "") or "").strip()
25817
+ if not secret_ref:
25818
+ raise RuntimeError("Secret reference is required.")
25819
+ items = _list_keychain_registry_entries(secret_ref=secret_ref)
25820
+ if not items:
25821
+ raise RuntimeError("No matching local Keychain secret was found.")
25822
+ entry = dict(items[0])
25823
+ spend_policy = _secret_spend_policy_from_args(args, entry)
25824
+ if not spend_policy:
25825
+ raise RuntimeError("--daily-spend-cap-usd is required when setting a spend policy.")
25826
+ entry["spend_policy"] = spend_policy
25827
+ entry["last_synced_at_utc"] = _now_utc()
25828
+ entry = _upsert_keychain_secret_registry_entry(entry)
25829
+ result = {
25830
+ "ok": True,
25831
+ "secret": _secret_payload_from_keychain_entry(entry),
25832
+ "entry": entry,
25833
+ "registry_path": str(_keychain_secret_registry_path()),
25834
+ "keychain_service": str(entry.get("keychain_service", "")).strip(),
25835
+ "keychain_account": str(entry.get("keychain_account", "")).strip(),
25836
+ "source": "keychain",
25837
+ }
25838
+ if args.json_output:
25839
+ _print_json(result)
25840
+ else:
25841
+ _print_secret_human(
25842
+ result["secret"],
25843
+ include_bindings=True,
25844
+ source="keychain",
25845
+ )
25846
+ _print_pairs(
25847
+ [
25848
+ ("keychain.service", result["keychain_service"]),
25849
+ ("keychain.account", result["keychain_account"]),
25850
+ ("registry.path", result["registry_path"]),
25851
+ ]
25852
+ )
25853
+ return 0
25854
+
25855
+
24871
25856
  def cmd_secrets_keychain_list(args: argparse.Namespace) -> int:
24872
25857
  provider = str(getattr(args, "provider", "") or "").strip()
24873
25858
  world_id, idea_id = _resolve_secret_scope_from_args(
@@ -24902,6 +25887,14 @@ def cmd_secrets_keychain_list(args: argparse.Namespace) -> int:
24902
25887
  )
24903
25888
  for row in items:
24904
25889
  print("---")
25890
+ spend_policy = _normalize_secret_spend_policy(
25891
+ row.get("spend_policy", {}) if isinstance(row.get("spend_policy"), dict) else {}
25892
+ )
25893
+ dashboard_limit = (
25894
+ spend_policy.get("dashboard_limit")
25895
+ if isinstance(spend_policy.get("dashboard_limit"), dict)
25896
+ else {}
25897
+ )
24905
25898
  _print_pairs(
24906
25899
  [
24907
25900
  ("secret.id", str(row.get("secret_id", "")).strip()),
@@ -24911,6 +25904,8 @@ def cmd_secrets_keychain_list(args: argparse.Namespace) -> int:
24911
25904
  ("secret.kind", str(row.get("kind", "")).strip()),
24912
25905
  ("secret.env_var_name", str(row.get("env_var_name", "")).strip()),
24913
25906
  ("secret.status", str(row.get("status", "")).strip()),
25907
+ ("secret.spend_policy.daily_cap_usd", str(spend_policy.get("daily_cap_usd", ""))),
25908
+ ("secret.spend_policy.dashboard_status", str(dashboard_limit.get("status", ""))),
24914
25909
  ("secret.binding_count", len(row.get("bindings", [])) if isinstance(row.get("bindings"), list) else 0),
24915
25910
  ("keychain.service", str(row.get("keychain_service", "")).strip()),
24916
25911
  ("keychain.account", str(row.get("keychain_account", "")).strip()),
@@ -27532,6 +28527,28 @@ def build_parser() -> argparse.ArgumentParser:
27532
28527
  action="store_true",
27533
28528
  help="Read the secret value from --env-var-name in the current process environment",
27534
28529
  )
28530
+ s_secrets_keychain_add.add_argument(
28531
+ "--daily-spend-cap-usd",
28532
+ type=float,
28533
+ default=None,
28534
+ help="Optional local daily USD spend cap for provider calls that use this key",
28535
+ )
28536
+ s_secrets_keychain_add.add_argument(
28537
+ "--dashboard-spend-cap-status",
28538
+ choices=["unconfirmed", "confirmed", "not_applicable"],
28539
+ default="",
28540
+ help="Record whether the matching provider dashboard spend limit has been confirmed",
28541
+ )
28542
+ s_secrets_keychain_add.add_argument(
28543
+ "--dashboard-project-id",
28544
+ default="",
28545
+ help="Optional provider dashboard project id associated with this key/cap",
28546
+ )
28547
+ s_secrets_keychain_add.add_argument(
28548
+ "--dashboard-url",
28549
+ default="",
28550
+ help="Optional provider dashboard URL where the spend cap is managed",
28551
+ )
27535
28552
  add_secret_scope_flags(s_secrets_keychain_add)
27536
28553
  s_secrets_keychain_add.add_argument("--purpose", default="", help="Optional project usage note when binding")
27537
28554
  s_secrets_keychain_add.add_argument(
@@ -27542,6 +28559,36 @@ def build_parser() -> argparse.ArgumentParser:
27542
28559
  add_json_flag(s_secrets_keychain_add)
27543
28560
  s_secrets_keychain_add.set_defaults(func=cmd_secrets_keychain_add, json_output=False)
27544
28561
 
28562
+ s_secrets_keychain_spend_policy = secrets_sub.add_parser(
28563
+ "keychain-spend-policy",
28564
+ help="Attach or update local spend policy metadata for an existing Keychain secret",
28565
+ )
28566
+ s_secrets_keychain_spend_policy.add_argument("secret_ref", help="Secret alias or id")
28567
+ s_secrets_keychain_spend_policy.add_argument(
28568
+ "--daily-spend-cap-usd",
28569
+ type=float,
28570
+ required=True,
28571
+ help="Local daily USD spend cap for provider calls that use this key",
28572
+ )
28573
+ s_secrets_keychain_spend_policy.add_argument(
28574
+ "--dashboard-spend-cap-status",
28575
+ choices=["unconfirmed", "confirmed", "not_applicable"],
28576
+ default="",
28577
+ help="Record whether the matching provider dashboard spend limit has been confirmed",
28578
+ )
28579
+ s_secrets_keychain_spend_policy.add_argument(
28580
+ "--dashboard-project-id",
28581
+ default="",
28582
+ help="Optional provider dashboard project id associated with this key/cap",
28583
+ )
28584
+ s_secrets_keychain_spend_policy.add_argument(
28585
+ "--dashboard-url",
28586
+ default="",
28587
+ help="Optional provider dashboard URL where the spend cap is managed",
28588
+ )
28589
+ add_json_flag(s_secrets_keychain_spend_policy)
28590
+ s_secrets_keychain_spend_policy.set_defaults(func=cmd_secrets_keychain_spend_policy, json_output=False)
28591
+
27545
28592
  s_secrets_keychain_list = secrets_sub.add_parser(
27546
28593
  "keychain-list",
27547
28594
  help="List local macOS Keychain copies known to ORP on this machine",
@@ -28471,6 +29518,78 @@ def build_parser() -> argparse.ArgumentParser:
28471
29518
  default="",
28472
29519
  help="Optional shared umbrella projects root used to link this repo's AGENTS.md and CLAUDE.md back to a parent guide",
28473
29520
  )
29521
+ s_init.add_argument(
29522
+ "--project-startup",
29523
+ action="store_true",
29524
+ help="Run the common new-project bootstrap: private GitHub remote when --github-repo is set, workspace main tracking, and Clawdad registration when installed",
29525
+ )
29526
+ s_init.add_argument(
29527
+ "--private-github",
29528
+ "--github-private",
29529
+ "--create-private-github-remote",
29530
+ dest="private_github",
29531
+ action="store_true",
29532
+ help="Create a private GitHub repository/remote with gh repo create when origin is absent",
29533
+ )
29534
+ s_init.add_argument(
29535
+ "--track-workspace-main",
29536
+ "--workspace-main",
29537
+ dest="track_workspace_main",
29538
+ action="store_true",
29539
+ help="Track this path in the ORP workspace ledger (default workspace: main)",
29540
+ )
29541
+ s_init.add_argument(
29542
+ "--workspace-name",
29543
+ default="main",
29544
+ help="Workspace ledger name for startup tracking (default: main)",
29545
+ )
29546
+ s_init.add_argument(
29547
+ "--workspace-title",
29548
+ default="",
29549
+ help="Optional title for the workspace tab recorded during startup",
29550
+ )
29551
+ s_init.add_argument(
29552
+ "--workspace-bootstrap-command",
29553
+ default="",
29554
+ help="Optional bootstrap command saved in the workspace tab, for example npm install",
29555
+ )
29556
+ s_init.add_argument(
29557
+ "--workspace-append",
29558
+ action="store_true",
29559
+ help="Append a new workspace session entry instead of replacing/upserting the path entry",
29560
+ )
29561
+ s_init.add_argument(
29562
+ "--with-clawdad",
29563
+ "--clawdad-delegation",
29564
+ dest="with_clawdad",
29565
+ action="store_true",
29566
+ help="Scaffold a Clawdad delegate brief and register the project when clawdad is installed",
29567
+ )
29568
+ s_init.add_argument(
29569
+ "--clawdad-slug",
29570
+ default="",
29571
+ help="Optional Clawdad project slug used with --with-clawdad",
29572
+ )
29573
+ s_init.add_argument(
29574
+ "--clawdad-description",
29575
+ default="",
29576
+ help="Optional Clawdad project description used with --with-clawdad",
29577
+ )
29578
+ s_init.add_argument(
29579
+ "--current-codex",
29580
+ action="store_true",
29581
+ help="Save the current CODEX_THREAD_ID as the Codex resume target when tracking workspace/Clawdad startup state",
29582
+ )
29583
+ s_init.add_argument(
29584
+ "--codex-session-id",
29585
+ default="",
29586
+ help="Explicit Codex session id to save in workspace/Clawdad startup state",
29587
+ )
29588
+ s_init.add_argument(
29589
+ "--startup-dry-run",
29590
+ action="store_true",
29591
+ help="Plan external startup actions without running gh, orp workspace, or clawdad commands",
29592
+ )
28474
29593
  s_init.add_argument(
28475
29594
  "--json",
28476
29595
  dest="json_output",