diff --git a/CLAUDE.md b/CLAUDE.md index aa3bb6c6f..382173182 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,11 +101,14 @@ When modifying any component, check if other components need corresponding updat | Hard Gates | Execution phase | exit code enforcement in quality-gate.sh | orchestrator.ts | | Persistence | Context Preservation | Environment-detected via `isGitRepo()` (git or filesystem) | state-tools.ts, git-worktree.ts | | Providers | Memory Providers section | `plugin/schemas/providers/*.json`, `plugin/providers/*.md` | config.sh | +| Provider OAuth (auth + token store) | N/A — implementation | Git-provider (github/gitlab) auth brokered through **haikumethod.ai** (NOT .com) — the Cloud Function in `deploy/auth-proxy/` runs a brokered authorization-code handshake (NOT the provider's native RFC-8628 device flow): `/cli/start` mints a **`session_id`** + `verification_url`, the browse-site callback POSTs the exchanged token to `/cli/complete`, the CLI polls `/cli/poll { session_id }` and reads the token bundle **spread at top level** of the `ready` response (matches `deploy/auth-proxy/src/cli.ts`). Token stored client-only in `~/.haiku/settings.json`. **Auth-when-needed (NOT auth-first):** `ensureProviderToken(provider)` (in `haiku_auth_login.ts`) returns a usable stored token or runs the handshake INLINE — the engine never tells the agent to "call haiku_auth_login first." Provider is the repo's (origin host). Tools: `haiku_auth_login` (explicit login), `haiku_auth_status`, `haiku_auth_logout`, `haiku_upload_proof` (upload proof to the delivery PR/MR over REST — auto-auths via `ensureProviderToken`; `proof_upload_auth_unavailable` only when auth genuinely can't be obtained). Token shape (`access_token`/`refresh_token`/`expires_at`/`scopes`/`account`/`host`) + read/write/clear live in `global-settings.ts`; provider detection (`parseGitRemote`/`providerFromHost`/`providerFromOrigin`/`readOriginRemoteUrl`) in `git-worktree.ts` | global-settings.ts, state/schemas/global-settings.ts, tools/orchestrator/haiku_auth_*.ts + haiku_upload_proof.ts, deploy/auth-proxy/ | +| PR/MR ops via stored token (Phase 4) | N/A — implementation | The engine drives PR/MR **create** + **mark-ready** over the provider REST API (`provider-rest.ts`, injectable fetch), **authenticating when needed**: `resolvePrRestContextEnsuringAuth` (origin host → provider → `ensureProviderToken`) obtains a token inline if none is stored — no "auth first." Any REST miss (incl. auth that couldn't be obtained — broker down / declined / headless) falls back to the `gh`/`glab` CLI (`openPullRequestCli`). The pre-open guard skips only when there's no CLI AND no recognized provider remote (`providerFromOrigin`). NO merge over REST — the human's merge is the approval signal (merge stays CLI/human-only). The two synchronous handler entry points (intent-main draft open in `haiku_intent_create`, repair PR in `haiku_repair`) stay CLI-only — a sync handler can't await REST/auth. REST contracts are doc-derived + mock-validated; CLI is the integration-proven path | provider-rest.ts, git-worktree.ts (`openPullRequest`/`markPullRequestReady` async + `resolvePrRestContextEnsuringAuth`), orchestrator/workflow/side-effects.ts | +| Closing brief (BRIEF.md) | Quality Enforcement | Per-stage user-facing `BRIEF.md` written twice via the engine-owned `haiku_write_brief { body }` tool — `pre` (the plan) on first write, `post` (what shipped) on the closing rewrite at stage finish. The tool takes ONLY the body; the engine resolves intent (from branch), stage (from cursor), and the `phase:` frontmatter (file absent → pre, present → post — the same signal `stageOwesClosingBrief` gates on, so frontmatter can't drift from the cursor). Frontmatter via gray-matter. The `write_brief` cursor action fires from `stageOwesBrief` (pre, BRIEF absent) and `stageOwesClosingBrief` (post, BRIEF exists + `phase != post`); two reachable surfaces (non-autopilot user-gate, autopilot/merge in `haiku_run_next`). Opt out with `brief: false` on intent FM | tools/orchestrator/haiku_write_brief.ts, orchestrator/workflow/cursor.ts (`stageOwesBrief`/`stageOwesClosingBrief`), prompts/stage/review/write_brief/ | | Harness | N/A (implementation detail) | `--harness ` MCP arg or `HAIKU_HARNESS` env var; capability registry in `harness.ts`, instruction adaptation in `harness-instructions.ts` | harness.ts, harness-instructions.ts, orchestrator.ts, server.ts | | Architecture (canonical) | N/A — implementation reference | `plugin/studios/ARCHITECTURE.md` — boundaries, lifecycle, hat patterns, FB-as-unit fix-loop semantics. Read before any structural change to studios, stages, hats, or workflow tools | ARCHITECTURE.md | | Workflow-managed file boundary | Quality Enforcement | PreToolUse hook denies generic Read/Write/Edit on `units/*.md`, `feedback/*.md`, `intent.md`, `stages/*/state.json`. Agents go through MCP tools only; redirect messages name the right tool | hooks/guard-workflow-fields.ts | | Unit CRUDL (MCP) | N/A — implementation | `haiku_unit_write` (create/rewrite, FM validators, DAG cycle detection, pending-only lifecycle), `haiku_unit_read` (body+title only — no FM exposed), `haiku_unit_get` (read ONE agent-authorable/corrective FM field — `quality_gates`/`outputs`/`inputs`/`depends_on`/`model`/`closes`/`title`; refuses FSM-driven fields with `unit_field_engine_only`; the read half of the corrective exemption so a gate-command fix is read→modify-one→write-back, not a whole-array clobber), `haiku_unit_set` (FM field update, lifecycle-enforced; `outputs`/`quality_gates` stay editable after a unit goes active — corrective exemption), `haiku_unit_delete` (pending only), `haiku_unit_list` | state-tools.ts | -| Feedback CRUDL (MCP) | N/A — implementation | `haiku_feedback_write` (body update, lifecycle-enforced), `haiku_feedback_read` (body+title only), `haiku_feedback` (create), `haiku_feedback_update` (status transitions, terminal-state-protected), `haiku_feedback_reject` (mark invalid), `haiku_feedback_delete`, `haiku_feedback_list`, `haiku_feedback_set_targets` / `haiku_feedback_set_severity` (classifier-hat backfill — write-once) | state-tools.ts | +| Feedback CRUDL (MCP) | N/A — implementation | `haiku_feedback_write` (body update, lifecycle-enforced), `haiku_feedback_read` (body+title only), `haiku_feedback` (create), `haiku_feedback_reject` (mark invalid/stale — stamps `rejected_at` so the open-feedback walk treats it as terminal; the v4 `haiku_feedback_update` status-transition tool was REMOVED — closure runs through the fix-loop's terminal hat `haiku_feedback_advance_hat`, and a stale `haiku_feedback_update` call returns `feedback_update_removed_in_v4`), `haiku_feedback_delete`, `haiku_feedback_list`, `haiku_feedback_set_targets` / `haiku_feedback_set_severity` (classifier-hat backfill — write-once) | state-tools.ts | | Feedback severity | Quality Enforcement | `severity:` on feedback frontmatter — `blocker` \| `high` \| `medium` \| `low` (`FEEDBACK_SEVERITIES` in `state/schemas/feedback.ts`). **Required** on the agent-facing `haiku_feedback` create tool (review agents classify as they file). User/SPA findings land severity-less (the `writeFeedbackFile` HTTP path omits it) and the `classifier` fix-hat backfills via `haiku_feedback_set_severity` (write-once, `severity_already_set` on re-call). The fix-loop dispatches highest-severity-first: `feedbackSeverityRank` orders the initial pool in `collectFeedbackDispatches` (cursor.ts) AND the slot-replenishment pick in `pickUndispatchedFbBlock` (state-tools.ts) — unclassified ranks as `medium`. Engine-authored FBs set explicit severity at write time (gate-blockers → `blocker`, drift → `high`). Surfaced on the wire (`FeedbackSeveritySchema`) + SPA badge (`FeedbackItem.tsx`) | state/schemas/feedback.ts (`FEEDBACK_SEVERITIES`, `feedbackSeverityRank`), state-tools.ts (`haiku_feedback_set_severity`, `pickUndispatchedFbBlock`), orchestrator/workflow/cursor.ts (`collectFeedbackDispatches`) | | Severity-gated fix-loop activation | Quality Enforcement | A finding is **blocking** (triggers a fix wave AND holds the stage gate) iff it's unclassified (null severity — the classifier must run) OR `feedbackSeverityRank(severity) <= fixSeverityThresholdRank()`. Threshold defaults to `high` (blocker+high block; medium+low ride along), overridable via **`HAIKU_FIX_SEVERITY_THRESHOLD`** env var (read at call time). `collectFeedbackDispatches` returns null (no wave) unless some open FB is `inFlight` OR blocking — so a lone reviewer nit never spins a worktree; the stage just advances and the nit stays open + advisory. Once a blocker DOES open a wave, the whole open set dispatches together (severity-ordered), sweeping the ridealong lows in the same pass. Agent findings carry severity at creation, so a reviewer's stream of `low`s is non-blocking from tick 1 | state/schemas/feedback.ts (`fixSeverityThresholdRank`, `isFixBlockingSeverity`), orchestrator/workflow/cursor.ts (`collectFeedbackDispatches` wave-active gate) | | Feedback anti-churn (re-review) | Quality Enforcement | Two LIVE, reword-resistant mechanisms keep reviewers from re-litigating settled findings. **Preventive:** `buildExistingFeedbackBlock` hands every review/approval/intent-review subagent ALL prior findings on scope — incl. closed/rejected — now with `severity`, the closure_reply (`resolved: …`) and reject reason (`dismissed: …`), and tells it to audit the delta / respect the dismissal, not re-file. **Detective:** `detectSettledDuplicate` (create-time, Jaccard token-overlap ≥ 0.6 on same target_unit) returns a `duplicate_warning` on the `haiku_feedback` response when a finding restates an already-closed/rejected one — advisory (a bad fix can legitimately re-raise), pointing the agent at the prior resolution + `haiku_feedback_reject`. The dispatch_approval prompt also tells the reviewer its units came back because the WORK changed (only changed units re-sent — cursor filters `!approvals[role]`), so audit the fix delta. NOTE: the old `computeFeedbackSignature` stage-iteration loop detector is dormant in v4 (`appendStageIteration` never gets `feedbackTitles`); the per-FB bolt cap remains the hard backstop | _helpers.ts (`buildExistingFeedbackBlock`), state-tools.ts (`detectSettledDuplicate`, `findingSimilarity`), prompts/stage/approve/dispatch_approval/subagent.eta.md | diff --git a/deploy/auth-proxy/README.md b/deploy/auth-proxy/README.md new file mode 100644 index 000000000..32b2462b6 --- /dev/null +++ b/deploy/auth-proxy/README.md @@ -0,0 +1,96 @@ +# auth-proxy + +Node 22 GCP Cloud Function (entry `authProxy`) live at `auth.haikumethod.ai`. +Brokers GitHub/GitLab OAuth so neither the browse site nor the CLI ever holds the +OAuth client secret. The secret lives only in Secret Manager and is read by this +function. + +Two surfaces share one provider exchange (`src/providers.ts`): + +| Surface | Endpoints | +| --- | --- | +| Browse site (Phase 1) | `POST /github/token`, `POST /gitlab/token` — code→token exchange | +| CLI device flow (Phase 2) | `POST /cli/start`, `POST /cli/complete`, `POST /cli/poll`, `POST /cli/refresh` | + +Phase 2 (`src/cli.ts`, `src/sessions.ts`) is wired into the same `authProxy` +entry: `index.ts` calls `handleCliRoute(req, res)` first; it owns any `/cli/*` +path and falls through to the existing browse-site routes otherwise. + +## CLI device flow + +``` +CLI auth-proxy browse site / provider + | POST /cli/start ----------->| | + | <- { session_id, | store PENDING (Firestore, | + | verification_url } | keyed by session_id + state) | + | | | + | (open verification_url) --------------------------------------> human approves + | | provider callback -----> | /{provider}/callback + | | <- POST /cli/complete | exchanges code→token + | | { state, access_token, ... } | then POSTs token here + | | flip session -> READY | + | POST /cli/poll ------------>| | + | <- { status: ready, | release ONCE, mark consumed | + | access_token, ... } | | +``` + +### Endpoint contracts + +**`POST /cli/start` `{ provider, host? }`** → `{ session_id, verification_url, expires_in }`. +Mints a 10-minute session + state, stores a PENDING record in Firestore keyed by +`session_id`, and returns the `verification_url` the human opens. The URL targets +the browse site's CLI authorize entry (`/oauth/cli/authorize`) carrying +`provider`, `host`, `state`, and `authorize_via` (the resolved provider authorize +endpoint). The browse site sends the human through the provider, and the existing +`/{provider}/callback` completes the exchange. Host-aware for enterprise GitHub / +self-managed GitLab. + +**`POST /cli/complete` `{ state, access_token, refresh_token?, expires_at?, scopes?, account?, host? }`** → `{ status: "ready" }`. +**This is the single server endpoint the browse-site callback must POST to.** After +the existing `/{provider}/callback` exchanges the authorization code for a token, +it POSTs the captured token bundle here keyed by `state`. The session flips to +`ready`. `scopes` accepts a string (space/comma separated) or an array. An +enterprise `host` echoed here overrides the one captured at start. + +> If the browse callback is purely client-side and cannot exchange the code +> server-side, it should instead POST the raw `code` to `/{provider}/token` +> (Phase 1) to get the bundle, then POST that bundle to `/cli/complete`. + +**`POST /cli/poll` `{ session_id }`** → +- `{ status: "pending" }` while awaiting approval, +- `{ status: "ready", access_token, refresh_token?, expires_at?, scopes?, account, provider, host }` **exactly once**, +- `{ status: "consumed" }` / `{ status: "expired" }` thereafter. + +The token is released a single time; the record is marked `consumed` and deleted +so it can never be replayed. + +**`POST /cli/refresh` `{ provider, host?, refresh_token }`** → a fresh token bundle. +Re-runs the provider exchange with `grant_type=refresh_token` using the held +client secret. Persists nothing. Host-aware. + +## Session store + +Firestore collection `cli_sessions`. Every record carries `expires_at` (epoch +seconds). Reads opportunistically delete expired records; a Firestore TTL policy +on `expires_at` (see `deploy/terraform/modules/auth-proxy/firestore.tf`) is the +backstop sweep. + +## Secrets / env + +| Env var | Source | +| --- | --- | +| `HAIKU_GITHUB_OAUTH_CLIENT_ID` / `HAIKU_GITHUB_OAUTH_CLIENT_SECRET` | Secret Manager (Phase 1) | +| `HAIKU_GITLAB_OAUTH_CLIENT_ID` / `HAIKU_GITLAB_OAUTH_CLIENT_SECRET` | Secret Manager (Phase 1) | +| `HAIKU__OAUTH_CLIENT_ID__` / ... | optional per-enterprise-host overrides | +| `ALLOWED_ORIGIN` | CORS allowlist (Phase 1); first entry is the default browse origin | +| `BROWSE_ORIGIN` | optional override for the `verification_url` origin | + +## Develop / test + +```sh +npm install +npm test # tsc -> dist/ then node --test test/ +``` + +Tests inject an in-memory session store and a fake `fetch` +(`setSessionStore`, `setFetchImpl`), so no GCP credentials or network are needed. diff --git a/deploy/auth-proxy/package-lock.json b/deploy/auth-proxy/package-lock.json index 82706548c..a247e23b1 100644 --- a/deploy/auth-proxy/package-lock.json +++ b/deploy/auth-proxy/package-lock.json @@ -1,1709 +1,2724 @@ { - "name": "haiku-auth-proxy", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "haiku-auth-proxy", - "version": "1.0.0", - "dependencies": { - "@google-cloud/functions-framework": "^3.0.0" - }, - "devDependencies": { - "typescript": "^5.0.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@google-cloud/functions-framework": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/@google-cloud/functions-framework/-/functions-framework-3.5.1.tgz", - "integrity": "sha512-J01F8mCAb9SEsEGOJjKR/1UHmZTzBWIBNjAETtiPx7Xie3WgeWTvMnfrbsZbaBG0oePkepRxo28R8Fi9B2J++A==", - "license": "Apache-2.0", - "dependencies": { - "@types/express": "^4.17.21", - "body-parser": "^1.18.3", - "cloudevents": "^8.0.2", - "express": "^4.21.2", - "minimist": "^1.2.8", - "on-finished": "^2.3.0", - "read-pkg-up": "^7.0.1", - "semver": "^7.6.3" - }, - "bin": { - "functions-framework": "build/src/main.js", - "functions-framework-nodejs": "build/src/main.js" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/@types/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "license": "MIT", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/express": { - "version": "4.17.25", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", - "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "^1" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "4.19.8", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", - "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/http-errors": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", - "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "license": "MIT" - }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "25.5.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", - "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@types/normalize-package-data": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", - "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", - "license": "MIT" - }, - "node_modules/@types/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", - "license": "MIT" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "license": "MIT" - }, - "node_modules/@types/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", - "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", - "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", - "license": "MIT", - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "<1" - } - }, - "node_modules/@types/serve-static/node_modules/@types/send": { - "version": "0.17.6", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", - "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "license": "MIT", - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.14.0", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/cloudevents": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/cloudevents/-/cloudevents-8.0.3.tgz", - "integrity": "sha512-wTixKNjfLeyj9HQpESvLVVO4xgdqdvX4dTeg1IZ2SCunu/fxVzCamcIZneEyj31V82YolFCKwVeSkr8zResB0Q==", - "license": "Apache-2.0", - "dependencies": { - "ajv": "^8.11.0", - "ajv-formats": "^2.1.1", - "json-bigint": "^1.0.0", - "process": "^0.11.10", - "util": "^0.12.4", - "uuid": "^8.3.2" - }, - "engines": { - "node": ">=16 <=22" - } - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.3", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.14.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/generator-function": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hosted-git-info": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", - "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", - "license": "ISC" - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-arguments": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", - "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "license": "MIT" - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-generator-function": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "license": "MIT", - "dependencies": { - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/json-bigint": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", - "license": "MIT", - "dependencies": { - "bignumber.js": "^9.0.0" - } - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "license": "MIT" - }, - "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/normalize-package-data": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", - "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", - "license": "BSD-2-Clause", - "dependencies": { - "hosted-git-info": "^2.1.4", - "resolve": "^1.10.0", - "semver": "2 || 3 || 4 || 5", - "validate-npm-package-license": "^3.0.1" - } - }, - "node_modules/normalize-package-data/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "license": "ISC", - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "license": "MIT" - }, - "node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/read-pkg": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", - "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", - "license": "MIT", - "dependencies": { - "@types/normalize-package-data": "^2.4.0", - "normalize-package-data": "^2.5.0", - "parse-json": "^5.0.0", - "type-fest": "^0.6.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/read-pkg-up": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", - "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", - "license": "MIT", - "dependencies": { - "find-up": "^4.1.0", - "read-pkg": "^5.2.0", - "type-fest": "^0.8.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-pkg/node_modules/type-fest": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", - "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=8" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/spdx-correct": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", - "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", - "license": "Apache-2.0", - "dependencies": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-exceptions": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", - "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", - "license": "CC-BY-3.0" - }, - "node_modules/spdx-expression-parse": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-license-ids": { - "version": "3.0.23", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", - "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", - "license": "CC0-1.0" - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/type-fest": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=8" - } - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "license": "MIT" - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/util": { - "version": "0.12.5", - "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", - "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "is-arguments": "^1.0.4", - "is-generator-function": "^1.0.7", - "is-typed-array": "^1.1.3", - "which-typed-array": "^1.1.2" - } - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/validate-npm-package-license": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", - "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", - "license": "Apache-2.0", - "dependencies": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", - "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - } - } + "name": "haiku-auth-proxy", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "haiku-auth-proxy", + "version": "1.0.0", + "dependencies": { + "@google-cloud/firestore": "^7.10.0", + "@google-cloud/functions-framework": "^3.0.0" + }, + "devDependencies": { + "typescript": "^5.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@google-cloud/firestore": { + "version": "7.11.6", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.11.6.tgz", + "integrity": "sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0", + "fast-deep-equal": "^3.1.1", + "functional-red-black-tree": "^1.0.1", + "google-gax": "^4.3.3", + "protobufjs": "^7.2.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/functions-framework": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@google-cloud/functions-framework/-/functions-framework-3.5.1.tgz", + "integrity": "sha512-J01F8mCAb9SEsEGOJjKR/1UHmZTzBWIBNjAETtiPx7Xie3WgeWTvMnfrbsZbaBG0oePkepRxo28R8Fi9B2J++A==", + "license": "Apache-2.0", + "dependencies": { + "@types/express": "^4.17.21", + "body-parser": "^1.18.3", + "cloudevents": "^8.0.2", + "express": "^4.21.2", + "minimist": "^1.2.8", + "on-finished": "^2.3.0", + "read-pkg-up": "^7.0.1", + "semver": "^7.6.3" + }, + "bin": { + "functions-framework": "build/src/main.js", + "functions-framework-nodejs": "build/src/main.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, + "node_modules/@tootallnate/once": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", + "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/caseless": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", + "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", + "license": "MIT" + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT" + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "license": "MIT" + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, + "node_modules/@types/request": { + "version": "2.48.13", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", + "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", + "license": "MIT", + "dependencies": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.5" + } + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "license": "MIT" + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cloudevents": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/cloudevents/-/cloudevents-8.0.3.tgz", + "integrity": "sha512-wTixKNjfLeyj9HQpESvLVVO4xgdqdvX4dTeg1IZ2SCunu/fxVzCamcIZneEyj31V82YolFCKwVeSkr8zResB0Q==", + "license": "Apache-2.0", + "dependencies": { + "ajv": "^8.11.0", + "ajv-formats": "^2.1.1", + "json-bigint": "^1.0.0", + "process": "^0.11.10", + "util": "^0.12.4", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">=16 <=22" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", + "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "license": "MIT" + }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz", + "integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.10.9", + "@grpc/proto-loader": "^0.7.13", + "@types/long": "^4.0.0", + "abort-controller": "^3.0.0", + "duplexify": "^4.0.0", + "google-auth-library": "^9.3.0", + "node-fetch": "^2.7.0", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^2.0.2", + "protobufjs": "^7.3.2", + "retry-request": "^7.0.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "license": "ISC" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/normalize-package-data/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/proto3-json-serializer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", + "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", + "license": "Apache-2.0", + "dependencies": { + "protobufjs": "^7.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/protobufjs": { + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.1.tgz", + "integrity": "sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/read-pkg": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^2.5.0", + "parse-json": "^5.0.0", + "type-fest": "^0.6.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", + "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", + "license": "MIT", + "dependencies": { + "find-up": "^4.1.0", + "read-pkg": "^5.2.0", + "type-fest": "^0.8.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/type-fest": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/retry-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", + "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", + "license": "MIT", + "dependencies": { + "@types/request": "^2.48.8", + "extend": "^3.0.2", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "license": "CC0-1.0" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "license": "MIT", + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "license": "MIT" + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/teeny-request": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", + "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", + "license": "Apache-2.0", + "dependencies": { + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.9", + "stream-events": "^1.0.5", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/teeny-request/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/teeny-request/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/teeny-request/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/teeny-request/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/teeny-request/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } } diff --git a/deploy/auth-proxy/package.json b/deploy/auth-proxy/package.json index d614211c5..d5d7d54e6 100644 --- a/deploy/auth-proxy/package.json +++ b/deploy/auth-proxy/package.json @@ -5,9 +5,11 @@ "main": "dist/index.js", "scripts": { "build": "tsc", - "start": "functions-framework --target=authProxy" + "start": "functions-framework --target=authProxy", + "test": "tsc && node --test test/*.test.mjs" }, "dependencies": { + "@google-cloud/firestore": "^7.10.0", "@google-cloud/functions-framework": "^3.0.0" }, "devDependencies": { diff --git a/deploy/auth-proxy/src/cli.ts b/deploy/auth-proxy/src/cli.ts new file mode 100644 index 000000000..d89eccc9f --- /dev/null +++ b/deploy/auth-proxy/src/cli.ts @@ -0,0 +1,300 @@ +/** + * CLI device-flow handlers (Phase 2), free of any GCP-runtime import so they can + * be unit-tested with plain fake (req, res) objects. index.ts wires the routes + * (`/cli/start`, `/cli/complete`, `/cli/poll`, `/cli/refresh`) into the existing + * authProxy entry alongside the Phase-1 browse-site `/github/token` + + * `/gitlab/token` handlers. + * + * Flow: + * 1. CLI POSTs /cli/start → we mint a session + state, store PENDING in + * Firestore, and return a verification_url pointing at the browse site's + * CLI authorize entry carrying the state. + * 2. The human approves; the browse site's existing /{provider}/callback + * exchanges code→token, then POSTs the bundle to /cli/complete keyed by + * state → the session flips to ready. + * 3. CLI polls /cli/poll → the token is released ONCE, then consumed. + * 4. /cli/refresh re-runs the provider exchange with grant_type=refresh_token. + * + * The provider exchange + refresh live in ./providers and are shared with the + * browse-site endpoints — no duplication. + */ + +import { randomBytes } from "node:crypto" +import { + authorizeEndpoint, + isProvider, + normalizeHost, + type Provider, + ProviderError, + refreshToken, + type TokenBundle, +} from "./providers.js" +import { + buildSession, + FirestoreSessionStore, + type SessionStore, +} from "./sessions.js" + +/** Minimal Express-shaped request/response (functions-framework compatible). */ +export interface HttpRequest { + method?: string + path?: string + body?: unknown +} +export interface HttpResponse { + status(code: number): unknown + json(body: unknown): unknown +} + +/** The browse-site origin that hosts the OAuth authorize entry + callback. */ +function browseOrigin(): string { + // Reuse the Phase-1 ALLOWED_ORIGIN allowlist's first entry as the canonical + // browse origin the verification_url points at. + const allowed = (process.env.ALLOWED_ORIGIN || "https://haikumethod.ai") + .split(",") + .map((o) => o.trim()) + .filter(Boolean) + return process.env.BROWSE_ORIGIN || allowed[0] || "https://haikumethod.ai" +} + +let store: SessionStore | null = null +function sessions(): SessionStore { + if (!store) store = new FirestoreSessionStore() + return store +} +/** Test seam — inject an in-memory store. */ +export function setSessionStore(s: SessionStore | null): void { + store = s +} + +/** Test seam — inject a fake fetch for the provider exchange. */ +let fetchImpl: typeof fetch | undefined +export function setFetchImpl(f: typeof fetch | undefined): void { + fetchImpl = f +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +function readBody(req: HttpRequest): Record { + const b = req.body + if (b && typeof b === "object") return b as Record + if (typeof b === "string" && b.length) { + try { + return JSON.parse(b) as Record + } catch { + return {} + } + } + return {} +} + +function str(v: unknown): string | undefined { + return typeof v === "string" && v.length ? v : undefined +} + +function genId(bytes = 24): string { + return randomBytes(bytes).toString("base64url") +} + +function fail( + res: HttpResponse, + status: number, + code: string, + message: string, +): void { + res.status(status) + res.json({ error: code, error_description: message }) +} + +function resolveProviderArg(res: HttpResponse, raw: unknown): Provider | null { + if (!isProvider(raw)) { + fail(res, 400, "invalid_provider", "provider must be 'github' or 'gitlab'") + return null + } + return raw +} + +// --------------------------------------------------------------------------- +// handlers +// --------------------------------------------------------------------------- + +async function start(req: HttpRequest, res: HttpResponse): Promise { + const body = readBody(req) + const provider = resolveProviderArg(res, body.provider) + if (!provider) return + const host = normalizeHost(provider, str(body.host)) + + const sessionId = genId(24) + const state = genId(24) + await sessions().create(buildSession({ sessionId, state, provider, host })) + + // The browse site owns the CLI authorize-entry route; it reads + // provider/host/state off the query string, sends the human through the + // provider authorize page, and after callback POSTs the captured token to + // /cli/complete keyed by this state. `authorize_via` documents the real + // provider authorize target for the website client. + const url = new URL("/oauth/cli/authorize", browseOrigin()) + url.searchParams.set("provider", provider) + url.searchParams.set("host", host) + url.searchParams.set("state", state) + url.searchParams.set("authorize_via", authorizeEndpoint(provider, host)) + + res.status(200) + res.json({ + session_id: sessionId, + verification_url: url.toString(), + expires_in: 600, + }) +} + +async function complete(req: HttpRequest, res: HttpResponse): Promise { + const body = readBody(req) + const state = str(body.state) + if (!state) { + fail(res, 400, "missing_state", "state is required") + return + } + const accessToken = str(body.access_token) + if (!accessToken) { + fail(res, 400, "missing_access_token", "access_token is required") + return + } + const session = await sessions().getByState(state) + if (!session) { + fail( + res, + 404, + "unknown_state", + "no pending session for that state (expired or invalid)", + ) + return + } + if (session.status !== "pending") { + fail(res, 409, "already_completed", "session already completed or consumed") + return + } + + const token: TokenBundle = { access_token: accessToken } + if (str(body.refresh_token)) token.refresh_token = str(body.refresh_token) + if (typeof body.expires_at === "number") token.expires_at = body.expires_at + if (Array.isArray(body.scopes)) { + token.scopes = (body.scopes as unknown[]).filter( + (s): s is string => typeof s === "string", + ) + } else if (str(body.scopes)) { + token.scopes = (str(body.scopes) as string).split(/[\s,]+/).filter(Boolean) + } + + const patch: Partial = { status: "ready", token } + if (str(body.account)) patch.account = str(body.account) + if (str(body.host)) patch.host = normalizeHost(session.provider, str(body.host)) + + await sessions().update(session.session_id, patch) + res.status(200) + res.json({ status: "ready" }) +} + +async function poll(req: HttpRequest, res: HttpResponse): Promise { + const body = readBody(req) + const sessionId = str(body.session_id) + if (!sessionId) { + fail(res, 400, "missing_session_id", "session_id is required") + return + } + const session = await sessions().getById(sessionId) + if (!session) { + // reaped-on-read for expired, or never existed + res.status(200) + res.json({ status: "expired" }) + return + } + if (session.status === "pending") { + res.status(200) + res.json({ status: "pending" }) + return + } + if (session.status === "consumed") { + res.status(200) + res.json({ status: "consumed" }) + return + } + // ready → release once, then delete so it can never be replayed. + const token = session.token + await sessions().update(session.session_id, { + status: "consumed", + token: undefined, + }) + await sessions() + .delete(session.session_id) + .catch(() => {}) + res.status(200) + res.json({ + status: "ready", + provider: session.provider, + host: session.host, + account: session.account, + ...token, + }) +} + +async function refresh(req: HttpRequest, res: HttpResponse): Promise { + const body = readBody(req) + const provider = resolveProviderArg(res, body.provider) + if (!provider) return + const rt = str(body.refresh_token) + if (!rt) { + fail(res, 400, "missing_refresh_token", "refresh_token is required") + return + } + const host = normalizeHost(provider, str(body.host)) + const bundle = await refreshToken({ + provider, + host, + refreshToken: rt, + fetchImpl, + }) + res.status(200) + res.json(bundle) +} + +/** + * Route a /cli/* request. Returns true if the path was a CLI route (handled), + * false otherwise so the caller can fall through to its own routing. + */ +export async function handleCliRoute( + req: HttpRequest, + res: HttpResponse, +): Promise { + const path = (req.path || "/").replace(/\/+$/, "").toLowerCase() || "/" + if (!path.startsWith("/cli/")) return false + + try { + switch (path) { + case "/cli/start": + await start(req, res) + return true + case "/cli/complete": + await complete(req, res) + return true + case "/cli/poll": + await poll(req, res) + return true + case "/cli/refresh": + await refresh(req, res) + return true + default: + fail(res, 404, "not_found", `no route for ${path}`) + return true + } + } catch (err) { + if (err instanceof ProviderError) { + fail(res, err.status, err.code, err.message) + return true + } + const message = err instanceof Error ? err.message : "internal error" + fail(res, 500, "server_error", message) + return true + } +} diff --git a/deploy/auth-proxy/src/index.ts b/deploy/auth-proxy/src/index.ts index 47fbd2564..9c7660fd7 100644 --- a/deploy/auth-proxy/src/index.ts +++ b/deploy/auth-proxy/src/index.ts @@ -1,11 +1,19 @@ import type { HttpFunction } from "@google-cloud/functions-framework" +import { handleCliRoute } from "./cli.js" // OAuth code→token exchange for GitHub and GitLab. // Deployed as a GCP Cloud Function (v2). // // Endpoints: -// POST /github/token — exchange GitHub authorization code -// POST /gitlab/token — exchange GitLab authorization code +// POST /github/token — exchange GitHub authorization code (browse site) +// POST /gitlab/token — exchange GitLab authorization code (browse site) +// POST /cli/start — begin a CLI device-flow handshake (Phase 2) +// POST /cli/complete — browse callback writes the captured token (Phase 2) +// POST /cli/poll — CLI polls for the token, one-time release (Phase 2) +// POST /cli/refresh — refresh an access token via the held secret (Phase 2) +// +// The CLI endpoints (src/cli.ts) reuse the same provider exchange + Secret +// Manager client secrets as the browse-site endpoints. const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGIN || "https://haikumethod.ai") .split(",") @@ -56,6 +64,12 @@ export const authProxy: HttpFunction = async (req, res) => { const path = req.path + // CLI device-flow routes (Phase 2). handleCliRoute returns true when it owns + // the path; falls through to the browse-site routes below otherwise. + if (await handleCliRoute(req, res)) { + return + } + if (path === "/github/token") { await handleGitHub(req, res) return diff --git a/deploy/auth-proxy/src/providers.ts b/deploy/auth-proxy/src/providers.ts new file mode 100644 index 000000000..ea721f51a --- /dev/null +++ b/deploy/auth-proxy/src/providers.ts @@ -0,0 +1,197 @@ +/** + * Provider OAuth primitives shared by the browse-site code→token exchange + * (POST /github/token, /gitlab/token in index.ts) and the CLI device-flow + * endpoints (cli.ts). + * + * The browse-site exchange and the CLI handshake both run the SAME code→token / + * refresh exchange against the SAME upstream with the SAME secret pulled from + * Secret Manager — so that logic lives here once and both callers import it. + * + * Secret env-var names match the Phase-1 deployment (terraform module): + * HAIKU_GITHUB_OAUTH_CLIENT_ID / HAIKU_GITHUB_OAUTH_CLIENT_SECRET + * HAIKU_GITLAB_OAUTH_CLIENT_ID / HAIKU_GITLAB_OAUTH_CLIENT_SECRET + */ + +export type Provider = "github" | "gitlab" + +export function isProvider(v: unknown): v is Provider { + return v === "github" || v === "gitlab" +} + +/** Default public host for each provider when no enterprise/self-managed host is given. */ +export const DEFAULT_HOST: Record = { + github: "github.com", + gitlab: "gitlab.com", +} + +/** + * Normalize a caller-supplied host. Accepts bare hostnames ("git.acme.com"), + * full origins ("https://git.acme.com"), and trailing slashes. Falls back to the + * provider's public host when absent. Enterprise GitHub and self-managed GitLab + * run the same OAuth paths on a different origin, so all we need is the host. + */ +export function normalizeHost(provider: Provider, host?: string | null): string { + const raw = (host ?? "").trim() + if (!raw) return DEFAULT_HOST[provider] + let h = raw.replace(/^https?:\/\//i, "") + h = h.replace(/\/.*$/, "") + return h || DEFAULT_HOST[provider] +} + +/** The OAuth token (code→token AND refresh) endpoint for a provider+host. */ +export function tokenEndpoint(provider: Provider, host: string): string { + if (provider === "github") { + // github.com and GitHub Enterprise Server share the path. + return `https://${host}/login/oauth/access_token` + } + // gitlab.com and self-managed GitLab share the path. + return `https://${host}/oauth/token` +} + +/** The OAuth authorize entry the browser is sent to. */ +export function authorizeEndpoint(provider: Provider, host: string): string { + if (provider === "github") { + return `https://${host}/login/oauth/authorize` + } + return `https://${host}/oauth/authorize` +} + +export interface ProviderCredentials { + clientId: string + clientSecret: string +} + +/** + * Resolve the OAuth app credentials for a provider from the environment. Secret + * Manager values are injected as env vars by the Cloud Function deployment (see + * deploy/terraform/modules/auth-proxy/main.tf). Phase 1 wired the + * HAIKU__OAUTH_CLIENT_ID/SECRET vars; the CLI reuses the same. + * + * An enterprise/self-managed host with its OWN registered OAuth app may set a + * host-scoped override, e.g. HAIKU_GITLAB_OAUTH_CLIENT_ID__GIT_ACME_COM (host + * dots/dashes → underscores, uppercased). + */ +export function resolveCredentials( + provider: Provider, + host: string, + env: NodeJS.ProcessEnv = process.env, +): ProviderCredentials { + const base = `HAIKU_${provider.toUpperCase()}_OAUTH` + const hostKey = host.replace(/[.-]/g, "_").toUpperCase() + const clientId = + env[`${base}_CLIENT_ID__${hostKey}`] ?? env[`${base}_CLIENT_ID`] + const clientSecret = + env[`${base}_CLIENT_SECRET__${hostKey}`] ?? env[`${base}_CLIENT_SECRET`] + if (!clientId || !clientSecret) { + throw new ProviderError( + 500, + "missing_provider_credentials", + `No OAuth credentials configured for ${provider} on ${host}`, + ) + } + return { clientId, clientSecret } +} + +export class ProviderError extends Error { + constructor( + public readonly status: number, + public readonly code: string, + message: string, + ) { + super(message) + this.name = "ProviderError" + } +} + +/** Token bundle returned by both code-exchange and refresh. */ +export interface TokenBundle { + access_token: string + refresh_token?: string + /** epoch seconds when access_token expires, when the provider returns expiry */ + expires_at?: number + scopes?: string[] + token_type?: string +} + +interface RawTokenResponse { + access_token?: string + refresh_token?: string + expires_in?: number + scope?: string + token_type?: string + error?: string + error_description?: string +} + +function shapeBundle(raw: RawTokenResponse): TokenBundle { + if (raw.error || !raw.access_token) { + throw new ProviderError( + 400, + raw.error ? `provider_${raw.error}` : "provider_no_token", + raw.error_description || raw.error || "Provider returned no access token", + ) + } + const bundle: TokenBundle = { access_token: raw.access_token } + if (raw.refresh_token) bundle.refresh_token = raw.refresh_token + if (typeof raw.expires_in === "number" && raw.expires_in > 0) { + bundle.expires_at = Math.floor(Date.now() / 1000) + raw.expires_in + } + if (raw.scope) bundle.scopes = raw.scope.split(/[\s,]+/).filter(Boolean) + if (raw.token_type) bundle.token_type = raw.token_type + return bundle +} + +/** POST to the provider token endpoint as JSON (matching Phase-1 exchange shape). */ +async function postToken( + url: string, + body: Record, + fetchImpl: typeof fetch = fetch, +): Promise { + const res = await fetchImpl(url, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(body), + }) + const text = await res.text() + let parsed: RawTokenResponse + try { + parsed = JSON.parse(text) as RawTokenResponse + } catch { + throw new ProviderError( + 502, + "provider_bad_response", + `Non-JSON response from provider (${res.status})`, + ) + } + if (!res.ok && !parsed.access_token && !parsed.error) { + throw new ProviderError( + 502, + "provider_http_error", + `Provider returned HTTP ${res.status}`, + ) + } + return parsed +} + +/** Re-run the token exchange with grant_type=refresh_token using the held secret. */ +export async function refreshToken(args: { + provider: Provider + host: string + refreshToken: string + env?: NodeJS.ProcessEnv + fetchImpl?: typeof fetch +}): Promise { + const { provider, host, refreshToken: rt, env, fetchImpl } = args + const { clientId, clientSecret } = resolveCredentials(provider, host, env) + const raw = await postToken( + tokenEndpoint(provider, host), + { + client_id: clientId, + client_secret: clientSecret, + grant_type: "refresh_token", + refresh_token: rt, + }, + fetchImpl, + ) + return shapeBundle(raw) +} diff --git a/deploy/auth-proxy/src/sessions.ts b/deploy/auth-proxy/src/sessions.ts new file mode 100644 index 000000000..077ec3d81 --- /dev/null +++ b/deploy/auth-proxy/src/sessions.ts @@ -0,0 +1,135 @@ +/** + * Firestore-backed CLI device-flow session store. + * + * A session walks: pending (created by /cli/start) → ready (token captured via + * the callback / /cli/complete) → consumed (released once by /cli/poll). + * Sessions self-expire: every record carries `expires_at` (epoch seconds); reads + * opportunistically delete anything past it. A Firestore TTL policy on + * `expires_at` (provisioned in terraform) is the backstop sweep. + * + * Lookups happen by two keys: + * - session id (what /cli/poll holds; the Firestore doc id) + * - state (what the OAuth round-trip carries back to /cli/complete; + * an indexed field queried on completion) + */ + +import type { Firestore } from "@google-cloud/firestore" +import type { Provider, TokenBundle } from "./providers.js" + +export type SessionStatus = "pending" | "ready" | "consumed" + +export interface SessionRecord { + session_id: string + state: string + provider: Provider + host: string + status: SessionStatus + created_at: number + expires_at: number + /** populated once status === "ready" */ + token?: TokenBundle + /** provider account login/username, when the callback captures it */ + account?: string +} + +export const SESSION_TTL_SECONDS = 10 * 60 // 10 minutes +export const COLLECTION = "cli_sessions" + +function nowSeconds(): number { + return Math.floor(Date.now() / 1000) +} + +/** Thin interface so tests can inject an in-memory fake. */ +export interface SessionStore { + create(rec: SessionRecord): Promise + getById(sessionId: string): Promise + getByState(state: string): Promise + update(sessionId: string, patch: Partial): Promise + delete(sessionId: string): Promise +} + +export class FirestoreSessionStore implements SessionStore { + private dbPromise: Promise | null = null + private readonly injected: Firestore | null + constructor(db?: Firestore) { + this.injected = db ?? null + } + + /** + * Lazily import @google-cloud/firestore so importing this module (e.g. from a + * test that only uses an in-memory store) never requires the dependency to be + * installed. The real Cloud Function path resolves it on first use. + */ + private async client(): Promise { + if (this.injected) return this.injected + if (!this.dbPromise) { + this.dbPromise = import("@google-cloud/firestore").then( + (m) => new m.Firestore(), + ) + } + return this.dbPromise + } + + private async col() { + const db = await this.client() + return db.collection(COLLECTION) + } + + async create(rec: SessionRecord): Promise { + const col = await this.col() + await col.doc(rec.session_id).set(rec) + } + + async getById(sessionId: string): Promise { + const col = await this.col() + const snap = await col.doc(sessionId).get() + if (!snap.exists) return null + return this.reapIfExpired(snap.data() as SessionRecord) + } + + async getByState(state: string): Promise { + const col = await this.col() + const q = await col.where("state", "==", state).limit(1).get() + if (q.empty) return null + return this.reapIfExpired(q.docs[0].data() as SessionRecord) + } + + async update(sessionId: string, patch: Partial): Promise { + const col = await this.col() + await col.doc(sessionId).set(patch, { merge: true }) + } + + async delete(sessionId: string): Promise { + const col = await this.col() + await col.doc(sessionId).delete() + } + + /** Opportunistic expiry sweep on read: delete + treat as gone. */ + private async reapIfExpired( + rec: SessionRecord, + ): Promise { + if (rec.expires_at <= nowSeconds()) { + await this.delete(rec.session_id).catch(() => {}) + return null + } + return rec + } +} + +export function buildSession(args: { + sessionId: string + state: string + provider: Provider + host: string +}): SessionRecord { + const created = nowSeconds() + return { + session_id: args.sessionId, + state: args.state, + provider: args.provider, + host: args.host, + status: "pending", + created_at: created, + expires_at: created + SESSION_TTL_SECONDS, + } +} diff --git a/deploy/auth-proxy/test/cli-flow.test.mjs b/deploy/auth-proxy/test/cli-flow.test.mjs new file mode 100644 index 000000000..f439b7c97 --- /dev/null +++ b/deploy/auth-proxy/test/cli-flow.test.mjs @@ -0,0 +1,264 @@ +import assert from "node:assert/strict" +import { afterEach, beforeEach, describe, it } from "node:test" +import { + handleCliRoute, + setFetchImpl, + setSessionStore, +} from "../dist/cli.js" +import { makeReq, makeRes, MemoryStore } from "./helpers.mjs" + +let mem +beforeEach(() => { + mem = new MemoryStore() + setSessionStore(mem) + setFetchImpl(undefined) +}) +afterEach(() => { + setSessionStore(null) + setFetchImpl(undefined) +}) + +describe("/cli/start", () => { + it("returns a session_id and a verification_url carrying the state", async () => { + const res = makeRes() + const owned = await handleCliRoute( + makeReq({ path: "/cli/start", body: { provider: "github" } }), + res, + ) + assert.equal(owned, true) + assert.equal(res.statusCode, 200) + assert.ok(res.body.session_id, "session_id present") + assert.ok( + res.body.verification_url.startsWith("https://"), + "verification_url is absolute", + ) + const u = new URL(res.body.verification_url) + assert.equal(u.searchParams.get("provider"), "github") + assert.ok(u.searchParams.get("state"), "state carried in url") + assert.equal(res.body.expires_in, 600) + + const stored = await mem.getById(res.body.session_id) + assert.equal(stored.status, "pending") + assert.equal(stored.provider, "github") + assert.equal(stored.host, "github.com") + assert.equal(stored.state, u.searchParams.get("state")) + }) + + it("is enterprise-host aware (self-managed GitLab)", async () => { + const res = makeRes() + await handleCliRoute( + makeReq({ + path: "/cli/start", + body: { provider: "gitlab", host: "https://git.acme.com/" }, + }), + res, + ) + const stored = await mem.getById(res.body.session_id) + assert.equal(stored.host, "git.acme.com") + const u = new URL(res.body.verification_url) + assert.equal( + u.searchParams.get("authorize_via"), + "https://git.acme.com/oauth/authorize", + ) + }) + + it("rejects an unknown provider", async () => { + const res = makeRes() + await handleCliRoute( + makeReq({ path: "/cli/start", body: { provider: "bitbucket" } }), + res, + ) + assert.equal(res.statusCode, 400) + assert.equal(res.body.error, "invalid_provider") + }) +}) + +describe("/cli/poll transitions", () => { + async function startSession() { + const res = makeRes() + await handleCliRoute( + makeReq({ path: "/cli/start", body: { provider: "github" } }), + res, + ) + return res.body + } + + it("pending → ready → consumed, releasing the token exactly once", async () => { + const { session_id } = await startSession() + const session = await mem.getById(session_id) + + // pending + let res = makeRes() + await handleCliRoute( + makeReq({ path: "/cli/poll", body: { session_id } }), + res, + ) + assert.deepEqual(res.body, { status: "pending" }) + + // complete via the callback contract (keyed by state) + res = makeRes() + await handleCliRoute( + makeReq({ + path: "/cli/complete", + body: { + state: session.state, + access_token: "gho_abc123", + refresh_token: "ghr_xyz", + scopes: "repo read:org", + account: "octocat", + }, + }), + res, + ) + assert.equal(res.statusCode, 200) + assert.equal(res.body.status, "ready") + + // poll → ready, token released + res = makeRes() + await handleCliRoute( + makeReq({ path: "/cli/poll", body: { session_id } }), + res, + ) + assert.equal(res.body.status, "ready") + assert.equal(res.body.access_token, "gho_abc123") + assert.equal(res.body.refresh_token, "ghr_xyz") + assert.deepEqual(res.body.scopes, ["repo", "read:org"]) + assert.equal(res.body.account, "octocat") + assert.equal(res.body.provider, "github") + + // second poll → no replay + res = makeRes() + await handleCliRoute( + makeReq({ path: "/cli/poll", body: { session_id } }), + res, + ) + assert.ok( + ["consumed", "expired"].includes(res.body.status), + "no replay", + ) + assert.equal(res.body.access_token, undefined, "token not re-released") + }) + + it("unknown session id reads as expired", async () => { + const res = makeRes() + await handleCliRoute( + makeReq({ path: "/cli/poll", body: { session_id: "nope" } }), + res, + ) + assert.equal(res.body.status, "expired") + }) + + it("expired session is reaped on poll", async () => { + const { session_id } = await startSession() + const rec = mem.byId.get(session_id) + rec.expires_at = Math.floor(Date.now() / 1000) - 1 + const res = makeRes() + await handleCliRoute( + makeReq({ path: "/cli/poll", body: { session_id } }), + res, + ) + assert.equal(res.body.status, "expired") + assert.equal(mem.byId.has(session_id), false, "reaped from store") + }) +}) + +describe("/cli/complete guards", () => { + it("rejects an unknown state", async () => { + const res = makeRes() + await handleCliRoute( + makeReq({ + path: "/cli/complete", + body: { state: "ghost", access_token: "x" }, + }), + res, + ) + assert.equal(res.statusCode, 404) + assert.equal(res.body.error, "unknown_state") + }) + + it("rejects a missing access_token", async () => { + const res = makeRes() + await handleCliRoute( + makeReq({ path: "/cli/complete", body: { state: "s" } }), + res, + ) + assert.equal(res.statusCode, 400) + assert.equal(res.body.error, "missing_access_token") + }) +}) + +describe("/cli/refresh", () => { + it("re-runs the provider exchange with grant_type=refresh_token against the right host", async () => { + process.env.HAIKU_GITLAB_OAUTH_CLIENT_ID = "cid" + process.env.HAIKU_GITLAB_OAUTH_CLIENT_SECRET = "csecret" + const calls = [] + setFetchImpl(async (url, init) => { + calls.push({ url, body: init.body }) + return new Response( + JSON.stringify({ + access_token: "new_at", + refresh_token: "new_rt", + expires_in: 7200, + scope: "api", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ) + }) + + const res = makeRes() + await handleCliRoute( + makeReq({ + path: "/cli/refresh", + body: { + provider: "gitlab", + host: "git.acme.com", + refresh_token: "old_rt", + }, + }), + res, + ) + assert.equal(res.statusCode, 200) + assert.equal(res.body.access_token, "new_at") + assert.equal(res.body.refresh_token, "new_rt") + + assert.equal(calls.length, 1) + assert.equal(calls[0].url, "https://git.acme.com/oauth/token") + const sent = JSON.parse(calls[0].body) + assert.equal(sent.grant_type, "refresh_token") + assert.equal(sent.refresh_token, "old_rt") + assert.equal(sent.client_id, "cid") + assert.equal(sent.client_secret, "csecret") + }) + + it("rejects a missing refresh_token", async () => { + const res = makeRes() + await handleCliRoute( + makeReq({ path: "/cli/refresh", body: { provider: "github" } }), + res, + ) + assert.equal(res.statusCode, 400) + assert.equal(res.body.error, "missing_refresh_token") + }) +}) + +describe("CLI router fall-through", () => { + it("does not own non-/cli/ paths", async () => { + const res = makeRes() + const owned = await handleCliRoute( + makeReq({ path: "/github/token", body: { code: "x" } }), + res, + ) + assert.equal(owned, false) + assert.equal(res.body, undefined, "did not write a response") + }) + + it("owns and 404s an unknown /cli/ path", async () => { + const res = makeRes() + const owned = await handleCliRoute( + makeReq({ path: "/cli/nope", body: {} }), + res, + ) + assert.equal(owned, true) + assert.equal(res.statusCode, 404) + }) +}) diff --git a/deploy/auth-proxy/test/helpers.mjs b/deploy/auth-proxy/test/helpers.mjs new file mode 100644 index 000000000..75612a18b --- /dev/null +++ b/deploy/auth-proxy/test/helpers.mjs @@ -0,0 +1,62 @@ +// Shared test doubles for the auth-proxy CLI handlers. + +/** In-memory SessionStore matching the SessionStore interface in sessions.ts. */ +export class MemoryStore { + constructor() { + this.byId = new Map() + } + async create(rec) { + this.byId.set(rec.session_id, { ...rec }) + } + async getById(sessionId) { + const rec = this.byId.get(sessionId) + if (!rec) return null + if (rec.expires_at <= Math.floor(Date.now() / 1000)) { + this.byId.delete(sessionId) + return null + } + return { ...rec } + } + async getByState(state) { + for (const rec of this.byId.values()) { + if (rec.state === state) { + if (rec.expires_at <= Math.floor(Date.now() / 1000)) { + this.byId.delete(rec.session_id) + return null + } + return { ...rec } + } + } + return null + } + async update(sessionId, patch) { + const rec = this.byId.get(sessionId) + if (!rec) return + const next = { ...rec, ...patch } + if ("token" in patch && patch.token === undefined) delete next.token + this.byId.set(sessionId, next) + } + async delete(sessionId) { + this.byId.delete(sessionId) + } +} + +/** Minimal Express-like Response capture. */ +export function makeRes() { + return { + statusCode: 200, + body: undefined, + status(code) { + this.statusCode = code + return this + }, + json(payload) { + this.body = payload + return this + }, + } +} + +export function makeReq({ method = "POST", path = "/", body = {} } = {}) { + return { method, path, body } +} diff --git a/deploy/auth-proxy/tsconfig.json b/deploy/auth-proxy/tsconfig.json index 1a74bd71a..d30cce56a 100644 --- a/deploy/auth-proxy/tsconfig.json +++ b/deploy/auth-proxy/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2022", "module": "commonjs", "moduleResolution": "node", - "ignoreDeprecations": "6.0", + "ignoreDeprecations": "5.0", "outDir": "dist", "rootDir": "src", "strict": true, diff --git a/deploy/terraform/modules/auth-proxy/firestore-outputs.tf b/deploy/terraform/modules/auth-proxy/firestore-outputs.tf new file mode 100644 index 000000000..cfa982b33 --- /dev/null +++ b/deploy/terraform/modules/auth-proxy/firestore-outputs.tf @@ -0,0 +1,11 @@ +# Outputs for the Phase-2 Firestore session store (additive to outputs.tf). + +output "firestore_database_id" { + description = "Firestore database backing the CLI device-flow session store." + value = var.firestore_database_id +} + +output "cli_sessions_collection" { + description = "Collection holding CLI device-flow sessions." + value = var.cli_sessions_collection +} diff --git a/deploy/terraform/modules/auth-proxy/firestore-variables.tf b/deploy/terraform/modules/auth-proxy/firestore-variables.tf new file mode 100644 index 000000000..4a450c3de --- /dev/null +++ b/deploy/terraform/modules/auth-proxy/firestore-variables.tf @@ -0,0 +1,31 @@ +# Variables for the Phase-2 Firestore session store (additive to variables.tf). + +variable "firestore_create_database" { + description = <<-EOT + Whether this module should create the Firestore database. Set to false if the + project already has a (default) Native-mode Firestore database — a GCP project + can hold only one. When false, the module skips google_firestore_database + + the firestore.googleapis.com enable and only manages the TTL policy / index / + IAM against the existing database. + EOT + type = bool + default = true +} + +variable "firestore_location_id" { + description = "Firestore location (e.g. nam5, us-central1). Immutable once the database exists." + type = string + default = "nam5" +} + +variable "firestore_database_id" { + description = "Firestore database id. The default Native database is '(default)'." + type = string + default = "(default)" +} + +variable "cli_sessions_collection" { + description = "Collection holding short-TTL CLI device-flow sessions. Must match COLLECTION in auth-proxy/src/sessions.ts." + type = string + default = "cli_sessions" +} diff --git a/deploy/terraform/modules/auth-proxy/firestore.tf b/deploy/terraform/modules/auth-proxy/firestore.tf new file mode 100644 index 000000000..e40f88c94 --- /dev/null +++ b/deploy/terraform/modules/auth-proxy/firestore.tf @@ -0,0 +1,81 @@ +# --------------------------------------------------------------------------- +# Firestore — CLI device-flow session store (Phase 2) +# +# Backs the /cli/start → /cli/complete → /cli/poll handshake in +# auth-proxy/src/cli.ts. Sessions are short-lived (10 min) and self-expire via +# an `expires_at` field; the TTL policy below is the backstop sweep behind the +# function's opportunistic delete-on-read. +# +# A GCP project holds exactly one Firestore database. If the project already has +# a (default) Native-mode database, set firestore_create_database = false so this +# module manages only the TTL policy + IAM against the existing database. +# --------------------------------------------------------------------------- + +resource "google_project_service" "firestore" { + count = var.firestore_create_database ? 1 : 0 + project = var.project_id + service = "firestore.googleapis.com" + disable_on_destroy = false +} + +resource "google_firestore_database" "sessions" { + count = var.firestore_create_database ? 1 : 0 + + project = var.project_id + name = var.firestore_database_id + location_id = var.firestore_location_id + type = "FIRESTORE_NATIVE" + delete_protection_state = "DELETE_PROTECTION_ENABLED" + + depends_on = [google_project_service.firestore] +} + +# TTL policy: Firestore deletes a document once the timestamp/numeric field named +# here is in the past. The function writes `expires_at` (epoch seconds) on every +# CLI session. +resource "google_firestore_field" "cli_sessions_ttl" { + project = var.project_id + database = var.firestore_database_id + collection = var.cli_sessions_collection + field = "expires_at" + + ttl_config {} + + # Leave normal indexing alone; only attach the TTL config. + index_config {} + + depends_on = [google_firestore_database.sessions] +} + +# Single-field index on `state` so /cli/complete can look a session up by state. +# (Single-field equality is auto-indexed by default; declared explicitly so the +# query contract is visible and survives any exemption changes.) +resource "google_firestore_field" "cli_sessions_state" { + project = var.project_id + database = var.firestore_database_id + collection = var.cli_sessions_collection + field = "state" + + index_config { + indexes { + order = "ASCENDING" + query_scope = "COLLECTION" + } + } + + depends_on = [google_firestore_database.sessions] +} + +# IAM — the function's runtime service account needs datastore access to read / +# write CLI session docs. The auth-proxy Cloud Function runs as the project's +# compute default service account (same SA the pristine module notes is granted +# secretmanager.secretAccessor manually). +data "google_project" "this" { + project_id = var.project_id +} + +resource "google_project_iam_member" "runtime_datastore_user" { + project = var.project_id + role = "roles/datastore.user" + member = "serviceAccount:${data.google_project.this.number}-compute@developer.gserviceaccount.com" +} diff --git a/packages/haiku/src/git-worktree.ts b/packages/haiku/src/git-worktree.ts index 5ad710215..530e086cb 100644 --- a/packages/haiku/src/git-worktree.ts +++ b/packages/haiku/src/git-worktree.ts @@ -31,11 +31,19 @@ import { } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" +import matter from "gray-matter" +import { readProviderToken } from "./global-settings.js" import { migrateIntent } from "./orchestrator/migrate-registry.js" // Named import also triggers the side-effect registration on // migrate-registry — without that registration, the post-merge sweep's // `migrateIntent("0", "4.0.0")` would throw "no migration path." import { hasV3CruftInIntent } from "./orchestrator/migrations/v0-to-v4.js" +import { + type CreatePrInput, + createPullRequestRest, + markPullRequestReadyRest, + type PrRestContext, +} from "./provider-rest.js" import { ensureHaikuGitignored, isGitRepo, @@ -872,20 +880,116 @@ export function detectPrTool(): "gh" | "glab" | null { return null } -/** Open a PR/MR from `branch` into `mainline` using the detected tool. - * Returns the PR URL on success, an error message on failure. - * Set `options.draft` to true to open a draft PR/MR (gh `--draft`, - * glab `--draft`). */ -export function openPullRequest( +/** Resolve a token-backed REST context from the repo origin + the GLOBAL token + * store. Returns null when there's no remote, the host isn't a supported + * provider, or no token is stored for that provider — in which case the caller + * falls back to the `gh` / `glab` CLI. The token's `host` is NOT matched against + * origin (a single provider token serves all repos on that provider); origin is + * only used for owner/repo/host coordinates. */ +export function resolvePrRestContext(): PrRestContext | null { + const origin = readOriginRemoteUrl() + if (!origin) return null + const parsed = parseGitRemote(origin) + if (!parsed) return null + const provider = providerFromHost(parsed.host) + if (!provider) return null + const token = readProviderToken(provider) + if (!token?.access_token) return null + return { + provider, + host: parsed.host, + owner: parsed.owner, + repo: parsed.repo, + token: token.access_token, + } +} + +/** Like `resolvePrRestContext`, but AUTHENTICATES WHEN NEEDED. If no usable + * token is stored for the repo's provider, it runs the broker handshake inline + * (browser + poll) and uses the fresh token — the engine never asks the agent + * to "go authenticate first." Returns null when there's no recognized provider + * remote, or when auth couldn't be obtained (broker unreachable / declined / + * timed out — e.g. headless CI, where `/cli/start` fails fast); the caller then + * falls back to the gh/glab CLI. The dynamic import of the login flow avoids a + * static import cycle with the auth tool, which imports this module's git + * helpers. */ +export async function resolvePrRestContextEnsuringAuth(): Promise { + const origin = readOriginRemoteUrl() + if (!origin) return null + const parsed = parseGitRemote(origin) + if (!parsed) return null + const provider = providerFromHost(parsed.host) + if (!provider) return null + const { ensureProviderToken } = await import( + "./tools/orchestrator/haiku_auth_login.js" + ) + const token = await ensureProviderToken(provider) + if (!token?.access_token) return null + return { + provider, + host: parsed.host, + owner: parsed.owner, + repo: parsed.repo, + token: token.access_token, + } +} + +/** Default fetch impl for the REST PR/MR helpers — wraps global fetch. */ +function defaultPrFetch( + ...a: Parameters +): ReturnType { + return fetch(...a) +} + +/** Open a PR/MR from `branch` into `mainline`, preferring the token-backed + * REST path when a provider token is stored, falling back to the `gh` / `glab` + * CLI otherwise (or on any REST miss). Returns the PR URL on success, an error + * message on failure. Set `options.draft` for a draft PR/MR. + * + * Async because the REST path is network I/O. The synchronous CLI-only body + * lives in `openPullRequestCli` so the sync repair-admin path can reuse it + * without going async. */ +export async function openPullRequest( + branch: string, + mainline: string, + title: string, + body: string, + options?: { draft?: boolean }, +): Promise<{ ok: boolean; url?: string; error?: string }> { + const draft = options?.draft === true + + // Token-backed REST path, AUTHENTICATING WHEN NEEDED: if no token is stored + // for the repo's provider, the broker handshake runs inline (the engine + // never asks the agent to authenticate first). On any REST miss — including + // auth that couldn't be obtained (broker down / declined / timed out) — we + // fall through to the gh/glab CLI. REST is preferred, never the only path. + const rest = await resolvePrRestContextEnsuringAuth() + if (rest) { + try { + const input: CreatePrInput = { branch, mainline, title, body, draft } + const { url } = await createPullRequestRest(rest, input, defaultPrFetch) + return { ok: true, url } + } catch { + // fall through to CLI + } + } + + return openPullRequestCli(branch, mainline, title, body, options) +} + +/** Synchronous CLI-only PR/MR open via `gh` / `glab`. The proven fallback that + * `openPullRequest` delegates to, and the path the sync repair-admin flow uses + * directly (no stored-token REST, no async). */ +export function openPullRequestCli( branch: string, mainline: string, title: string, body: string, options?: { draft?: boolean }, ): { ok: boolean; url?: string; error?: string } { + const draft = options?.draft === true const tool = detectPrTool() if (!tool) return { ok: false, error: "no PR tool (gh/glab) found on PATH" } - const draft = options?.draft === true try { if (tool === "gh") { // Check for an existing PR for this branch first to avoid duplicates @@ -1136,6 +1240,69 @@ export function pushBranchToOrigin(branch: string): { * * Returns null when the origin URL can't be parsed or the host isn't * recognised (the caller should print the branch name + base instead). */ +/** Parse an `origin` URL into host / owner / repo (repo keeps subgroup + * slashes). Handles SSH (`git@host:owner/repo.git`) and HTTPS. Null when + * unparseable. Shared by the statusline browse deep-links so they map a + * remote to coordinates the same way the PR/MR fallback does. */ +export function parseGitRemote( + originRaw: string, +): { host: string; owner: string; repo: string } | null { + if (!originRaw) return null + let host = "" + let path = "" + const sshMatch = originRaw.match(/^[^@\s]+@([^:]+):(.+?)(?:\.git)?$/) + if (sshMatch) { + host = sshMatch[1] + path = sshMatch[2] + } else { + try { + const u = new URL(originRaw) + host = u.hostname + path = u.pathname.replace(/^\/+/, "").replace(/\.git$/, "") + } catch { + return null + } + } + const segments = path.split("/").filter(Boolean) + if (segments.length < 2) return null + return { host, owner: segments[0], repo: segments.slice(1).join("/") } +} + +/** Read the repo's `origin` URL via git, or null when there's no remote. */ +export function readOriginRemoteUrl(): string | null { + try { + const out = execFileSync("git", ["remote", "get-url", "origin"], { + encoding: "utf8", + stdio: "pipe", + }).trim() + return out || null + } catch { + return null + } +} + +/** Map a remote host to a supported provider name. `includes()` is intentional + * so GitHub Enterprise (`github.company.com`) and self-hosted GitLab + * (`gitlab.internal`) resolve too. Returns null for unrecognized hosts. */ +export function providerFromHost(host: string): "github" | "gitlab" | null { + const h = host.toLowerCase() + if (h.includes("github")) return "github" + if (h.includes("gitlab")) return "gitlab" + return null +} + +/** The repo's provider, from the origin remote host, or null. Sync + cheap — + * used by the pre-open guards to decide whether a PR/MR open is even possible: + * a recognized provider remote means auth-when-needed can obtain a token (so + * the open is worth attempting) even when no `gh`/`glab` CLI is on PATH. */ +export function providerFromOrigin(): "github" | "gitlab" | null { + const origin = readOriginRemoteUrl() + if (!origin) return null + const parsed = parseGitRemote(origin) + if (!parsed) return null + return providerFromHost(parsed.host) +} + export function buildCompareUrl( headBranch: string, baseBranch: string, @@ -1203,12 +1370,12 @@ export interface OpenStageMrResult { * failure. The agent's external_review_requested response surfaces * whichever URL we produced — programmatic when possible, manual link * when not. */ -export function openStagePullRequest(opts: { +export async function openStagePullRequest(opts: { slug: string stage: string title?: string body?: string -}): OpenStageMrResult { +}): Promise { const branch = `haiku/${opts.slug}/${opts.stage}` const base = `haiku/${opts.slug}/main` const title = @@ -1236,7 +1403,7 @@ export function openStagePullRequest(opts: { } if (push.ok) { - const pr = openPullRequest(branch, base, title, body) + const pr = await openPullRequest(branch, base, title, body) if (pr.ok && pr.url) { result.createdUrl = pr.url result.message = `Stage PR opened: ${pr.url}` @@ -1311,7 +1478,11 @@ export function openIntentDraftPullRequest(opts: { } if (push.ok) { - const pr = openPullRequest(branch, base, title, body, { draft: true }) + // CLI-only: this opens the intent-main draft PR once, from the + // synchronous haiku_intent_create handler. The token-backed REST path is + // only wired through the async-reachable PR ops (stage-PR open, both + // ready-flips) — see openPullRequest. A sync handler can't await REST. + const pr = openPullRequestCli(branch, base, title, body, { draft: true }) if (pr.ok && pr.url) { result.createdUrl = pr.url result.message = `Draft PR opened: ${pr.url}` @@ -1347,12 +1518,12 @@ export function openIntentDraftPullRequest(opts: { * it. Stage start never blocks on this. Shape mirrors * openIntentDraftPullRequest — the only differences are the branch/base * pair (stage → intent-main) and the default copy. */ -export function openStageDraftPullRequest(opts: { +export async function openStageDraftPullRequest(opts: { slug: string stage: string title?: string body?: string -}): OpenIntentMrResult { +}): Promise { const branch = `haiku/${opts.slug}/${opts.stage}` const base = `haiku/${opts.slug}/main` const title = opts.title ?? `H·AI·K·U: ${opts.slug} — stage ${opts.stage}` @@ -1378,7 +1549,7 @@ export function openStageDraftPullRequest(opts: { } if (push.ok) { - const pr = openPullRequest(branch, base, title, body, { draft: true }) + const pr = await openPullRequest(branch, base, title, body, { draft: true }) if (pr.ok && pr.url) { result.createdUrl = pr.url result.message = `Draft stage PR opened: ${pr.url}` @@ -1406,10 +1577,10 @@ export function openStageDraftPullRequest(opts: { * Detects provider from URL hostname: `gh pr ready ` or * `glab mr update --ready`. Best-effort; the caller logs failures * and continues with the user's merge action. */ -export function markPullRequestReady(url: string): { +export async function markPullRequestReady(url: string): Promise<{ ok: boolean error?: string -} { +}> { if (!url) return { ok: false, error: "empty url" } let parsed: URL try { @@ -1417,6 +1588,19 @@ export function markPullRequestReady(url: string): { } catch { return { ok: false, error: `not a valid URL: ${url}` } } + + // Token-backed REST path, authenticating when needed; any REST miss (incl. + // auth that couldn't be obtained) falls through to the CLI. + const rest = await resolvePrRestContextEnsuringAuth() + if (rest) { + try { + await markPullRequestReadyRest(rest, url, defaultPrFetch) + return { ok: true } + } catch { + // fall through to CLI + } + } + try { // Loose `includes()` match (vs `=== "github.com"`) is intentional // here: catches GitHub Enterprise (`github.company.com`) and self- @@ -2572,6 +2756,68 @@ export function readFileFromBranch( } } +/** Read an intent's `intent.md` from the canonical intent-main branch + * (`haiku//main`) — the fork source of every stage branch — without + * checking it out. In a diverged checkout (a stage branch carrying a stale + * plan from the pre-2026-05-28 buggy drop), this is the authoritative copy. + * Returns null in filesystem mode or when main can't be read, so callers + * fall back to the working-tree intent.md. */ +export function readIntentFileAtMain(slug: string): string | null { + const intentMain = `haiku/${slug}/main` + const rel = `.haiku/intents/${slug}/intent.md` + return readFileFromBranch(intentMain, rel) +} + +/** Remove `stage` from intent.md's `stages` array on the intent-main branch, + * via a transient worktree so the engine's current checkout is never + * disturbed. Used by the pre-tick optional-stage divergence heal to propagate + * a stage-branch drop UP to main (the fork source of every future stage + * branch) so the cursor — which reads main — stops re-arriving at a stage the + * branches already dropped. Best-effort: swallows errors (e.g. main already + * checked out elsewhere) so a tick never hard-fails; the next tick retries, + * and the explicit haiku_drop_stage path also lands on main. Returns true + * only when it actually wrote the drop. No-op in filesystem mode or when + * intent-main doesn't exist. */ +export function dropStageFromMainPlan(slug: string, stage: string): boolean { + if (!isGitRepo()) return false + const intentMain = `haiku/${slug}/main` + if (!branchExists(intentMain)) return false + const rel = `.haiku/intents/${slug}/intent.md` + try { + return withWorktreeOnBranch(intentMain, (tmpPath) => { + const abs = join(tmpPath, rel) + if (!existsSync(abs)) return false + // Build a FRESH data object via spread — never mutate the object + // gray-matter returns (it caches + shares parsed.data). + const parsed = matter(readFileSync(abs, "utf8")) + const current = Array.isArray(parsed.data.stages) + ? (parsed.data.stages as unknown[]).filter( + (s): s is string => typeof s === "string", + ) + : [] + if (!current.includes(stage)) return false // already healed + const nextStages = current.filter((s) => s !== stage) + if (nextStages.length === 0) return false // never strip to empty + const nextData = { ...parsed.data, stages: nextStages } + fsWriteFileSync(abs, matter.stringify(parsed.content, nextData)) + tryRun(["git", "-C", tmpPath, "add", "--", rel]) + tryRun([ + "git", + "-C", + tmpPath, + "commit", + "-m", + `haiku: heal optional-stage divergence — drop '${stage}' from ${slug} plan on intent main`, + "--", + rel, + ]) + return true + }) + } catch { + return false + } +} + /** Absolute path to a unit's worktree under `.haiku/worktrees/{slug}/{unit}`. */ export function unitWorktreePath(slug: string, unit: string): string { return join(primaryRepoRoot(), ".haiku", "worktrees", slug, unit) diff --git a/packages/haiku/src/global-settings.ts b/packages/haiku/src/global-settings.ts new file mode 100644 index 000000000..174d5f5dc --- /dev/null +++ b/packages/haiku/src/global-settings.ts @@ -0,0 +1,159 @@ +// global-settings.ts — user-level (GLOBAL) settings at `~/.haiku/settings.json`. +// +// DISTINCT from the per-project `.haiku/settings.json` (the `haiku_settings_*` +// tools + `plugin/schemas/settings.schema.json`). This file holds machine-wide +// state that isn't repo-specific — today, the provider OAuth tokens captured by +// the haikumethod.ai auth broker (GitHub / GitLab), used by the engine's MR/PR +// operations and the proof-upload tool so they hit the provider REST API +// directly instead of shelling out to `gh`/`glab`. +// +// Tokens are CLIENT-ONLY: the broker relays a token here once and persists +// nothing long-term; refresh re-runs the relay (the website holds the OAuth +// client secret, not us). So this file is the sole durable home for a token. +// +// Path override: HAIKU_GLOBAL_DIR (absolute) wins over `~/.haiku` — load-bearing +// for tests (they point it at a temp dir so a test run never reads/writes the +// real user file) and for any sandbox that relocates the home tree. +// +// All readers tolerate a missing / empty / corrupt file (return null / empty, +// never throw to the caller) — a broken global file must never break a tick. +// Writes are atomic (temp + rename) with 0600 perms so a token is never +// world-readable and a crashed write can't leave a half-written file. + +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + writeFileSync, +} from "node:fs" +import { homedir } from "node:os" +import { dirname, join } from "node:path" +import { + type GlobalSettings, + PROVIDER_NAMES, + type ProviderName, + type ProviderToken, + validateGlobalSettingsSchema, +} from "./state/schemas/global-settings.js" + +/** The global `~/.haiku` dir (or `$HAIKU_GLOBAL_DIR` when set). */ +export function globalHaikuDir(): string { + const override = process.env.HAIKU_GLOBAL_DIR?.trim() + if (override && override.length > 0) return override + return join(homedir(), ".haiku") +} + +/** Absolute path to `~/.haiku/settings.json`. */ +export function globalSettingsPath(): string { + return join(globalHaikuDir(), "settings.json") +} + +/** Read + validate the global settings, or an empty object when the file is + * absent / unreadable / fails the schema. Never throws. */ +function readGlobalSettings(): GlobalSettings { + const path = globalSettingsPath() + if (!existsSync(path)) return {} + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(path, "utf8")) + } catch { + return {} // corrupt JSON — treat as empty rather than crash a tick + } + if (!validateGlobalSettingsSchema(parsed)) return {} + return parsed as GlobalSettings +} + +/** Atomic 0600 write of the whole settings object. Temp-then-rename so a + * crash mid-write can't truncate the live file; chmod before rename so the + * token is never briefly world-readable. */ +function writeGlobalSettings(settings: GlobalSettings): void { + const path = globalSettingsPath() + mkdirSync(dirname(path), { recursive: true }) + const tmp = `${path}.tmp-${process.pid}` + writeFileSync(tmp, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 }) + try { + chmodSync(tmp, 0o600) + } catch { + /* best-effort — some filesystems ignore mode */ + } + renameSync(tmp, path) +} + +/** Read one provider's stored token, or null when absent. */ +export function readProviderToken( + provider: ProviderName, +): ProviderToken | null { + const settings = readGlobalSettings() + return settings.providers?.[provider] ?? null +} + +/** Persist one provider's token (replacing any prior). Validated before write — + * an invalid bundle throws (callers build it from the broker response, which + * the tool layer schema-checks first). */ +export function writeProviderToken( + provider: ProviderName, + token: ProviderToken, +): void { + const settings = readGlobalSettings() + const providers = { ...(settings.providers ?? {}) } + providers[provider] = token + const next: GlobalSettings = { ...settings, providers } + if (!validateGlobalSettingsSchema(next)) { + throw new Error( + `global_settings_invalid: refusing to write malformed provider token for '${provider}'`, + ) + } + writeGlobalSettings(next) +} + +/** Remove one provider's token. Returns true when a token was actually + * present (so callers can report `was_connected`). No-op + false otherwise. */ +export function clearProviderToken(provider: ProviderName): boolean { + const settings = readGlobalSettings() + if (!settings.providers?.[provider]) return false + const providers = { ...settings.providers } + delete providers[provider] + writeGlobalSettings({ ...settings, providers }) + return true +} + +/** A token's expiry state — `null` expires_at means non-expiring (a PAT-style + * token), so `expired` is false. */ +function isExpired(token: ProviderToken): boolean { + if (!token.expires_at) return false + const t = Date.parse(token.expires_at) + if (Number.isNaN(t)) return false // unparseable → don't claim expired + return t <= Date.now() +} + +/** Connected-provider summary for `haiku_auth_status` — NEVER includes the + * access/refresh token values, only the safe metadata + a derived `expired`. */ +export interface ProviderStatus { + provider: ProviderName + account: string | null + scopes: string[] + host: string + expires_at: string | null + expired: boolean +} + +/** List every connected provider's status (token values omitted). */ +export function listConnectedProviders(): ProviderStatus[] { + const settings = readGlobalSettings() + const out: ProviderStatus[] = [] + for (const provider of PROVIDER_NAMES) { + const token = settings.providers?.[provider] + if (!token) continue + out.push({ + provider, + account: token.account ?? null, + scopes: token.scopes ?? [], + host: token.host, + expires_at: token.expires_at ?? null, + expired: isExpired(token), + }) + } + return out +} diff --git a/packages/haiku/src/http/session-routes.ts b/packages/haiku/src/http/session-routes.ts index 9b10ec1cd..b0cb096b2 100644 --- a/packages/haiku/src/http/session-routes.ts +++ b/packages/haiku/src/http/session-routes.ts @@ -26,6 +26,7 @@ import { import { HAIKU_UI_HTML } from "../haiku-ui-html.js" import { broadcastIntent } from "../intent-broadcaster.js" import { closeMicroApp } from "../micro-app.js" +import { resolveActiveStageWithFallback } from "../orchestrator/studio.js" import { buildApprovalRecord, buildReviewRecord, @@ -555,30 +556,35 @@ export function registerSessionRoutes(instance: FastifyInstance): void { return } const slug = session.intent_slug + // readActiveStage falls back (stamp → derived-from-canonical-main → + // last plan stage), so "" here means the intent has no resolvable + // plan at all (fully complete, or pre-plan). That must NOT block the + // review: /api/feedback already wrote the feedback files, and this + // advance signal's only remaining job is to wake the gate so the + // engine ticks. Proceed at intent scope; the stage-scoped user-slot + // stamping below is guarded on a real stage. Reviewing feedback must + // never fail on no_active_stage. const targetStage = readActiveStage(slug) if (!targetStage) { logFeedbackAction({ reqId: req.id, action: "advance", - status: 409, + status: 200, intent: slug, - detail: "no_active_stage", - }) - reply.status(409).send({ - error: "no_active_stage", - detail: "intent has no active stage", + detail: "no_active_stage_soft: waking gate at intent scope", }) - return } - const stageOpenFbs = readFeedbackFiles(slug, targetStage).filter( - (item) => - item.status === "pending" || - item.status === "fixing" || - item.status === "addressed", - ) + const stageOpenFbs = targetStage + ? readFeedbackFiles(slug, targetStage).filter( + (item) => + item.status === "pending" || + item.status === "fixing" || + item.status === "addressed", + ) + : [] let stampedUserSlots = false - if (stageOpenFbs.length === 0) { + if (targetStage && stageOpenFbs.length === 0) { try { stampUserSlotsForCompletedStage(slug, targetStage) stampedUserSlots = true @@ -646,14 +652,11 @@ export function registerSessionRoutes(instance: FastifyInstance): void { } function readActiveStage(slug: string): string { - const intentFile = join(intentDir(slug), "intent.md") - if (!existsSync(intentFile)) return "" - try { - const { data } = parseFrontmatter(readFileSync(intentFile, "utf8")) - return (data.active_stage as string) || "" - } catch { - return "" - } + // Full fallback chain (stamp → derived-from-canonical-main → last plan + // stage) so a diverged or unstamped intent still resolves a stage — the + // SPA advance/feedback paths must never see "" turn into a no_active_stage + // 409. + return resolveActiveStageWithFallback(slug) } /** Stamp `reviews.user` and `approvals.user` (when missing) on every diff --git a/packages/haiku/src/orchestrator/prompts/stage/elaborate/decompose/index.ts b/packages/haiku/src/orchestrator/prompts/stage/elaborate/decompose/index.ts index ddbc7b6fb..f2fbd8a89 100644 --- a/packages/haiku/src/orchestrator/prompts/stage/elaborate/decompose/index.ts +++ b/packages/haiku/src/orchestrator/prompts/stage/elaborate/decompose/index.ts @@ -695,6 +695,17 @@ function renderElaborate(ctx: PromptBuilderContext): string { ) if (outputExpectations) sections.push(outputExpectations) + // Verification gates over prose: a raw placeholder word-grep false-positives + // on any artifact that MENTIONS the banned words (deferral notes, self-check + // lines). Applies to every stage that authors `quality_gates:` — knowledge + // stages especially, whose artifacts are prose. (Reported 2026-05-28 on the + // `merge-trains-integration-gate` migration intent: a + // `! grep -nE '\b(TBD|TODO|FIXME|XXX)\b'` gate generated ~5 of 11 findings + // on metalinguistic prose.) Content directive, not workflow mechanics. + sections.push( + '## Quality Gates: don\'t grep prose for placeholder words\n\nA gate like `! grep -nE \'\\b(TBD|TODO|FIXME|XXX)\\b\' ` false-positives on any artifact that merely MENTIONS those words — a deferral note ("TBD in the next stage"), a self-check line ("…not left as a bare TODO"). On a prose / knowledge artifact this fails the gate on legitimate content. If you need a no-unfilled-placeholder check, SCOPE it: match only standalone placeholder lines (`^\\s*(TODO|TBD|FIXME)\\b`), exclude fenced code and quoted spans, or check for the specific unfilled TEMPLATE markers you actually left (e.g. ``, `__TODO__`) rather than any occurrence of the word. Never gate a prose document on a bare word-grep.', + ) + // Build-class stages: every producing unit must carry a `quality_gates:` // field. `haiku_unit_write` rejects a build-stage unit that declares // `outputs:` but omits `quality_gates:`, so authoring it up front avoids a diff --git a/packages/haiku/src/orchestrator/prompts/stage/review/write_brief/index.ts b/packages/haiku/src/orchestrator/prompts/stage/review/write_brief/index.ts index 441e8f4bb..57d682c98 100644 --- a/packages/haiku/src/orchestrator/prompts/stage/review/write_brief/index.ts +++ b/packages/haiku/src/orchestrator/prompts/stage/review/write_brief/index.ts @@ -1,12 +1,22 @@ // orchestrator/prompts/stage/review/write_brief/index.ts — the // user-facing stage BRIEF dispatch. // -// Cursor returns `write_brief { stage }` once per stage in the PRE-execute -// review walk, after the adversarial reviews sign off on the spec and -// before the review user gate, when no `BRIEF.md` exists yet. A dedicated -// briefer subagent reads the planned units + intent + inputs + knowledge -// and writes `stages//BRIEF.md` — a plain-language summary for the -// human reviewing the plan at the gate. +// Cursor returns `write_brief { stage, phase }` for the SAME `BRIEF.md` +// artifact at two points in a stage's life: +// +// - `phase: "pre"` — PRE-execute review walk, after the adversarial +// reviews sign off on the spec and before the review user gate, when no +// `BRIEF.md` exists yet. The briefer reads the planned units + intent + +// inputs + knowledge and writes the brief as "this is what I am going +// to do" — a plain-language summary for the human reviewing the PLAN. +// - `phase: "post"` — POST-execute, after every approval is signed, the +// quality gates have run, and observations are recorded, but before the +// stage closes. The briefer rewrites the SAME `BRIEF.md` in place as +// "this is what I did" — reading the outputs, closed feedback, and +// iterations — and stamps `phase: post` into the brief's OWN frontmatter. +// That in-content signal (not a sibling marker, which could drift from the +// content) is what `stageOwesClosingBrief` reads to advance to +// complete_stage on the next tick. // // The briefer mandate is ENGINE-OWNED and universal (every stage produces // something worth summarizing), inlined from `subagent.eta.md`, with the @@ -26,8 +36,16 @@ const SUBAGENT_TEMPLATE = loadTemplate(import.meta.url, "subagent.eta.md") export default definePromptBuilder(({ slug, action }) => { const stage = (action.stage as string) || "" + // `phase` distinguishes the PRE-execute brief ("what I am going to do") + // from the POST-execute closing brief ("what I did"). Default to "pre" + // for back-compat with any caller that omits it. + const phase = (action.phase as string) === "post" ? "post" : "pre" - const subagentPrompt = eta.renderString(SUBAGENT_TEMPLATE, { slug, stage }) + const subagentPrompt = eta.renderString(SUBAGENT_TEMPLATE, { + slug, + stage, + phase, + }) const dispatchBlock = emitSubagentDispatchBlock({ unit: "brief", @@ -37,9 +55,9 @@ export default definePromptBuilder(({ slug, action }) => { stage: stage || undefined, agentType: "general-purpose", promptBody: subagentPrompt, - heading: `### Subagent: stage brief (\`${stage}\`)`, + heading: `### Subagent: stage brief (\`${stage}\`, ${phase})`, omitBolt: true, }) - return eta.renderString(TEMPLATE, { slug, stage, dispatchBlock }) + return eta.renderString(TEMPLATE, { slug, stage, dispatchBlock, phase }) }) diff --git a/packages/haiku/src/orchestrator/studio.ts b/packages/haiku/src/orchestrator/studio.ts index e1fc1e9e0..40a661d2e 100644 --- a/packages/haiku/src/orchestrator/studio.ts +++ b/packages/haiku/src/orchestrator/studio.ts @@ -21,8 +21,12 @@ import { existsSync, readdirSync, readFileSync } from "node:fs" import { join } from "node:path" import matter from "gray-matter" import { resolvePluginRoot } from "../config.js" +import { readIntentFileAtMain } from "../git-worktree.js" import { intentDir, parseFrontmatter } from "../state-tools.js" import { resolveStudio, studioSearchPaths } from "../studio-reader.js" +// Call-time-only ESM cycle (binding used inside a function body, never at +// module eval): cursor.ts statically imports this module. Safe in ESM. +import { isStageComplete } from "./workflow/cursor.js" function readFrontmatter(filePath: string): Record { if (!existsSync(filePath)) return {} @@ -80,6 +84,86 @@ export function resolveIntentStages( return studioStages } +/** Read the intent's CANONICAL stage plan — `intent.stages` as it exists on + * intent main (`haiku//main`), the fork source every stage branch is + * cut from. A diverged stage-branch checkout can carry a stale plan (e.g. the + * old buggy `haiku_drop_stage` that wrote the drop to the stage branch instead + * of main); main is authoritative. In filesystem mode (no branches) or when + * main can't be read, falls back to the working-tree intent.md so behavior is + * identical for healthy intents. */ +export function resolveCanonicalIntentStages( + slug: string, + studio: string, + workingTreeFm: Record, +): string[] { + const mainRaw = readIntentFileAtMain(slug) + if (mainRaw) { + const mainFm = parseFrontmatter(mainRaw).data + return resolveIntentStages(mainFm, studio) + } + return resolveIntentStages(workingTreeFm, studio) +} + +/** Derive the active stage from the CANONICAL (intent-main) plan, walking it + * against on-disk stage-completion. This is the branch-agnostic analog of + * `findCurrentStage`: it reads the plan from main so a diverged stage-branch + * checkout can't produce a contradictory active stage, but it still judges + * completion from the working-tree (fast-forwarded) stage dirs the same way + * the cursor does. Returns null when every canonical stage is complete (the + * intent is at completion) or the plan is empty. */ +export function findCurrentStageFromMain( + slug: string, + studio: string, +): string | null { + const iDir = intentDir(slug) + const intentFile = join(iDir, "intent.md") + const workingTreeFm = existsSync(intentFile) + ? parseFrontmatter(readFileSync(intentFile, "utf8")).data + : {} + const stages = resolveCanonicalIntentStages(slug, studio, workingTreeFm) + if (stages.length === 0) return null + const mode = + typeof workingTreeFm.mode === "string" && workingTreeFm.mode.length > 0 + ? (workingTreeFm.mode as string) + : "continuous" + for (const stage of stages) { + if (!isStageComplete(iDir, studio, stage, mode)) return stage + } + return null +} + +/** Resolve the active stage for SPA + tool readers with a full fallback chain: + * stamped `intent.active_stage` → derived from the CANONICAL (intent-main) + * plan → the last stage in that plan. Never returns "" when a plan exists. + * + * This is the single source the stamp-only readers (`resolveActiveStage` in + * state-tools, `readActiveStage` in session-routes) delegate to. The stamp + * alone goes stale on a diverged stage-branch checkout (the old buggy drop) + * or is simply absent on a freshly migrated intent; returning "" there is + * what produced the SPA's `no_active_stage` 409 on feedback submit. Walking + * the canonical plan recovers a real stage; the last-stage fallback covers + * the "every stage already complete" edge so an intent at completion still + * resolves a stage for stage-scoped writes. Returns "" only when there is + * genuinely no plan (no studio, empty plan) — callers must tolerate that. */ +export function resolveActiveStageWithFallback(slug: string): string { + const intentFile = join(intentDir(slug), "intent.md") + if (!existsSync(intentFile)) return "" + let fm: Record + try { + fm = parseFrontmatter(readFileSync(intentFile, "utf8")).data + } catch { + return "" + } + const stamped = (fm.active_stage as string) || "" + if (stamped) return stamped + const studio = (fm.studio as string) || "" + if (!studio) return "" + const derived = findCurrentStageFromMain(slug, studio) + if (derived) return derived + const plan = resolveCanonicalIntentStages(slug, studio, fm) + return plan.length > 0 ? plan[plan.length - 1] : "" +} + /** Filter cross-stage references (a stage's `inputs:` entries) to those whose * source stage is still in the intent's plan — the auto-ignore that lets an * optional stage be dropped without orphaning a downstream dependency. diff --git a/packages/haiku/src/orchestrator/tool-defs.ts b/packages/haiku/src/orchestrator/tool-defs.ts index e779efa51..cf5351f41 100644 --- a/packages/haiku/src/orchestrator/tool-defs.ts +++ b/packages/haiku/src/orchestrator/tool-defs.ts @@ -18,6 +18,9 @@ // const exports. The contract test is the safe alternative. import { + HAIKU_AUTH_LOGIN_INPUT_SCHEMA, + HAIKU_AUTH_LOGOUT_INPUT_SCHEMA, + HAIKU_AUTH_STATUS_INPUT_SCHEMA, HAIKU_AWAIT_GATE_INPUT_SCHEMA, HAIKU_DEBUG_INPUT_SCHEMA, HAIKU_DISCOVERY_COMPLETE_INPUT_SCHEMA, @@ -33,10 +36,42 @@ import { HAIKU_STAGE_ELABORATION_SEAL_INPUT_SCHEMA, HAIKU_STAGE_RESET_INPUT_SCHEMA, HAIKU_UNIT_RESET_INPUT_SCHEMA, + HAIKU_UPLOAD_PROOF_INPUT_SCHEMA, + HAIKU_WRITE_BRIEF_INPUT_SCHEMA, } from "../state/schemas/index.js" import { jsonSchemaOf } from "../state/schemas/inputs/_validate.js" export const orchestratorToolDefs = [ + { + name: "haiku_auth_status", + description: + "Show which Git providers (github / gitlab) the engine is authenticated to for MR/PR operations and proof upload. Returns each connected provider's account, scopes, host, expiry, and whether the token is expired — never the token value itself. Optional `provider` narrows to one.", + inputSchema: jsonSchemaOf(HAIKU_AUTH_STATUS_INPUT_SCHEMA), + }, + { + name: "haiku_auth_login", + description: + "Authenticate a Git provider (github / gitlab) via the haikumethod.ai OAuth broker so the engine can drive PR/MR ops and proof upload over the provider REST API. Opens a verification URL in the browser, polls until you approve, and stores the token in ~/.haiku/settings.json. `provider` is optional — inferred from the repo's origin host when omitted. Never returns the token value.", + inputSchema: jsonSchemaOf(HAIKU_AUTH_LOGIN_INPUT_SCHEMA), + }, + { + name: "haiku_auth_logout", + description: + "Disconnect a Git provider (github | gitlab) by clearing its stored auth token from ~/.haiku/settings.json. Idempotent — clearing an unconnected provider returns ok with was_connected:false.", + inputSchema: jsonSchemaOf(HAIKU_AUTH_LOGOUT_INPUT_SCHEMA), + }, + { + name: "haiku_upload_proof", + description: + "Upload a runtime-verification proof file to the intent's / stage's change request over the provider REST API. GitHub → release asset; GitLab → project uploads API + MR-ready markdown ref. Provider is detected from the repo origin; the bearer comes from ~/.haiku/settings.json (run haiku_auth_login first). Returns the durable proof URL.", + inputSchema: jsonSchemaOf(HAIKU_UPLOAD_PROOF_INPUT_SCHEMA), + }, + { + name: "haiku_write_brief", + description: + "Write the current stage's user-facing BRIEF.md. Supply ONLY the markdown body (no frontmatter, no intent, no stage). The engine resolves the intent + stage from the current cursor position and stamps the `phase:` frontmatter itself — `pre` for the first write (the plan) and `post` when rewriting the existing brief at stage finish (what shipped). Called in-flow during the `write_brief` cursor action; the action's `phase` tells you which prose to write, but you never specify it.", + inputSchema: jsonSchemaOf(HAIKU_WRITE_BRIEF_INPUT_SCHEMA), + }, { name: "haiku_run_next", description: diff --git a/packages/haiku/src/orchestrator/workflow/cursor.ts b/packages/haiku/src/orchestrator/workflow/cursor.ts index 0a6a45f07..d444cf59a 100644 --- a/packages/haiku/src/orchestrator/workflow/cursor.ts +++ b/packages/haiku/src/orchestrator/workflow/cursor.ts @@ -292,7 +292,20 @@ export type CursorAction = // gate opens, a persistent repo artifact, and a website-browse surface. // User-facing only — the focused work agents never read it. Forward-only // (see `stageOwesBrief`): never interrupts a stage already executing. - | { kind: "write_brief"; stage: string } + // + // `phase` distinguishes the two firings of the SAME `BRIEF.md` artifact: + // - `"pre"` — PRE-execute (the original firing above): "this is what I + // am going to do". Written from the planned units before any + // code lands, gated on BRIEF.md absence. + // - `"post"` — POST-execute (2026-05-28): "this is what I did". Rewrites + // the SAME `BRIEF.md` in place after execution + adversarial + // approval + quality gates, before the stage closes. Gated on + // the brief's OWN frontmatter `phase:` reaching `post` + // (BRIEF.md already exists from the pre firing, so absence + // can't gate it; an in-content signal can't drift from the + // content the way a sibling marker can). See + // `stageOwesClosingBrief`. + | { kind: "write_brief"; stage: string; phase: "pre" | "post" } // `role` is the lead role (back-compat / single-role shadow); `dispatches` // carries the full parallel batch when >1 (the adversarial fan-out — same // shape as `dispatch_review`). `spec` and `user` dispatch single (serial). @@ -969,6 +982,57 @@ export function stageOwesBrief( return true } +/** + * Does this stage still owe its CLOSING `BRIEF.md` rewrite before it can + * close? The pre-execute brief said "this is what I am going to do"; the + * closing brief rewrites the SAME `BRIEF.md` in place to say "this is what I + * did" — the post-execution summary the human sees once the work has landed. + * + * It fires POST-execute, after every approval is signed and the quality + * gates have run, BEFORE complete_stage. Two conditions, both required: + * 1. `BRIEF.md` EXISTS — the closing brief REWRITES the pre firing's file + * in place; with no file there is nothing to rewrite, so the gate is a + * no-op (a stage that never wrote a pre-execute brief — brief skipped, + * or a legacy/fixture intent — must fall straight through to its merge, + * not stall waiting for a closing brief on a file that will never + * appear). This is the mirror of the pre-execute brief, which gates on + * `BRIEF.md` ABSENCE. + * 2. The brief's OWN frontmatter `phase:` is not yet `post`. The signal + * lives INSIDE the artifact, not in a sibling marker file — so it can + * never drift from the content (a marker can say "finalized" next to a + * stale pre-brief, or be missing next to a fresh post-brief; the + * frontmatter is the content's state). The pre-execute brief stamps + * `phase: pre`; the closing brief rewrites the file AND stamps + * `phase: post` in the same write, then the next tick reads `post` and + * falls through to complete_stage. A brief with no/other `phase` is + * treated as still-owed (defaults to `pre`). + * + * Forward-only by construction: the cursor only walks the frontier + * (incomplete) stage, so a completed/merged stage is never re-entered to + * write a closing brief. + */ +export function stageOwesClosingBrief( + intentDir: string, + stage: string, +): boolean { + if (!isBriefEnabled(intentDir)) return false + const briefPath = join(intentDir, "stages", stage, "BRIEF.md") + // Nothing to rewrite if the pre-execute brief never authored BRIEF.md. + if (!existsSync(briefPath)) return false + try { + // Read-only: gray-matter caches by content and returns a SHARED object; + // we only read `.data.phase`, never mutate it (see the shared-cache + // gotcha that bit the output-existence gate). + const parsed = matter(readFileSync(briefPath, "utf8")) + const phase = (parsed.data as { phase?: unknown } | undefined)?.phase + return phase !== "post" + } catch { + // Unreadable/unparseable brief → treat as not-yet-finalized so the + // closing brief re-fires (a re-run beats a silent false-complete). + return true + } +} + /** * Find the stage the cursor is currently positioned in — the first * stage that isn't complete on disk. @@ -1574,10 +1638,34 @@ function walkIntentTrack(args: { readFm(join(intentDir, "intent.md"))?.data ?? {}, studio, ) + // Hold discovery/decompose while the keep-or-drop decision is + // pending — but ONLY when there's a human to hold for. Surfacing + // them would have the agent fan out discovery + decompose subagents + // on a stage it may immediately drop (throwaway work), and (worse) + // decompose authors units that flip the `units.length === 0` offer + // condition off, stranding the drop path. Recording the conversation + // (writes elaboration.md) clears this one-shot offer; the NEXT tick + // surfaces discovery + decompose normally on keep, or nothing on + // drop. So the offer carries ONLY the conversation-class signal(s). + // + // AUTOPILOT EXCEPTION: autopilot has no user to make a keep-or-drop + // call, so there's nothing to hold for — it auto-keeps and drives + // the stage to completion in one autonomous pass. Trimming the + // signal set there strands the autopilot harness (it acts on the + // signals in the action; a perpetually-re-emitted conversation-only + // offer that its drive loop can't clear trips the deadlock detector + // → loop_halted). So in autopilot we emit the FULL signal set, same + // as a mandatory stage. The `optional_offer` flag still rides along + // for surfaces that want to note it, but discovery isn't held. + const holdForDecision = mode !== "autopilot" + const OFFER_SIGNALS = new Set(["conversation", "verify_conversation"]) + const offerSignals = holdForDecision + ? signalsUnmet.filter((s) => OFFER_SIGNALS.has(s.signal)) + : signalsUnmet return { kind: "elaborate_loop", stage, - signals_unmet: signalsUnmet, + signals_unmet: offerSignals, optional_offer: true, dependents: computeStageDependents(studio, stage, planStages), } @@ -1721,7 +1809,7 @@ function walkIntentTrack(args: { (u) => Boolean(u.fm.started_at) || pickIterations(u.fm).length > 0, ) if (stageOwesBrief(intentDir, stage, anyUnitStarted)) { - return { kind: "write_brief", stage } + return { kind: "write_brief", stage, phase: "pre" } } } // Brief written → the deferred review user gate. @@ -2011,6 +2099,19 @@ function walkIntentTrack(args: { units: first.units, } } + // Closing BRIEF (#17), non-autopilot path. Every adversarial + // approval + quality gate is signed and only the human approval + // gate remains — rewrite the SAME BRIEF.md from "what I'm going + // to do" to "what I did" BEFORE the user reviews it, so the gate + // shows the post-execution summary. Reached only when `user` is + // in approvalRoles (autopilot omits it — that path's closing + // brief fires in run_next's complete_stage interception instead). + // One-shot via the brief's `phase: post` frontmatter; once the + // rewrite stamps it, the next tick falls through to the user_gate + // below. + if (stageOwesClosingBrief(intentDir, stage)) { + return { kind: "write_brief", stage, phase: "post" } + } return { kind: "user_gate", stage, @@ -2070,6 +2171,16 @@ function walkIntentTrack(args: { } } + // (Closing BRIEF #17 does NOT gate here. When every approval — including + // `user` — is signed, this stage's units are complete, so findCurrentStage + // advances to the NEXT stage and this per-stage walk never runs for the + // just-finished one. The closing brief instead fires at two REACHABLE + // points on the same `phase: post` frontmatter one-shot: (1) non-autopilot, + // in step 8 right before the `user_gate` return — while the stage is still + // frontier with user approval pending; (2) autopilot / prior-stage-merge, + // in haiku_run_next's complete_stage interception, before the observations + // gate.) + // 8b. Every approval signed AND observations recorded. Emit // `complete_stage` — a SEMANTIC action ("this stage is // done"), NOT a VCS verb. The underlying implementation diff --git a/packages/haiku/src/orchestrator/workflow/heal-optional-stage-divergence.ts b/packages/haiku/src/orchestrator/workflow/heal-optional-stage-divergence.ts new file mode 100644 index 000000000..e9c7df4b5 --- /dev/null +++ b/packages/haiku/src/orchestrator/workflow/heal-optional-stage-divergence.ts @@ -0,0 +1,89 @@ +// orchestrator/workflow/heal-optional-stage-divergence.ts +// +// Pre-tick self-repair for a plan divergence left by the pre-2026-05-28 buggy +// `haiku_drop_stage`, which wrote an optional-stage drop to whatever branch +// was checked out (the optional stage's own branch) instead of intent main. +// +// Symptom (the deadlock, reported on `release-healthy-signals`): intent main's +// `intent.stages` still lists an optional stage (e.g. `design`) that the +// stage-branch checkout already dropped. The cursor reads main, so it keeps +// arriving at the still-listed stage and re-offering the keep-or-drop; the +// drop guard (pre-fix, reading the branch) saw it as non-active and refused +// with `drop_stage_not_active`. Loop → deadlock-halt. +// +// Layer 2 already lets the agent's explicit drop succeed (the guard now reads +// the canonical main plan). This gate closes the loop WITHOUT the agent +// re-calling the tool: on tick, it detects the divergence and propagates the +// drop UP to main. The existing downstream sync (mainline → intent main → +// stage) then re-propagates the corrected plan to every branch. +// +// Detection is cheap (two plan reads, no checkout). The write only fires when +// a divergence is actually present, the diverged stage is `optional: true`, +// AND it is unstarted everywhere on disk (no units, no elaboration.md — +// guaranteed for a never-started dropped stage). Idempotent; a no-op for +// healthy intents and in filesystem mode. + +import { existsSync, readdirSync, readFileSync } from "node:fs" +import { join } from "node:path" +import { + dropStageFromMainPlan, + readIntentFileAtMain, +} from "../../git-worktree.js" +import { intentDir, parseFrontmatter } from "../../state-tools.js" +import { resolveIntentStages, resolveStageOptional } from "../studio.js" + +/** Detect + heal an optional-stage plan divergence between intent main and the + * current (stage-branch) checkout. Returns the list of stages it healed + * (usually empty). Safe to call every tick. */ +export function healOptionalStageDivergence( + slug: string, + studio: string, +): string[] { + if (!studio) return [] + const mainRaw = readIntentFileAtMain(slug) + if (!mainRaw) return [] // fs mode / main unreadable — nothing canonical to heal + let mainPlan: string[] + try { + mainPlan = resolveIntentStages(parseFrontmatter(mainRaw).data, studio) + } catch { + return [] + } + if (mainPlan.length === 0) return [] + + const iDir = intentDir(slug) + const wtFile = join(iDir, "intent.md") + if (!existsSync(wtFile)) return [] + let wtPlan: string[] + try { + wtPlan = resolveIntentStages( + parseFrontmatter(readFileSync(wtFile, "utf8")).data, + studio, + ) + } catch { + return [] + } + const wtSet = new Set(wtPlan) + + // Stages present on main but absent from the working-tree plan: the drop + // landed on a branch and never propagated to main. + const diverged = mainPlan.filter((s) => !wtSet.has(s)) + if (diverged.length === 0) return [] + + const healed: string[] = [] + for (const stage of diverged) { + // Only OPTIONAL stages are droppable — a mandatory stage missing from a + // branch plan is a different (corruption) problem, not a buggy drop. + if (!resolveStageOptional(studio, stage)) continue + // Unstarted everywhere: a dropped optional stage never ran, so it has no + // units and no elaboration.md. If either exists the stage carries real + // work — NOT the buggy-drop case; leave it for the cursor. + const stageDir = join(iDir, "stages", stage) + const unitsDir = join(stageDir, "units") + const hasUnits = + existsSync(unitsDir) && + readdirSync(unitsDir).some((f) => f.endsWith(".md")) + if (hasUnits || existsSync(join(stageDir, "elaboration.md"))) continue + if (dropStageFromMainPlan(slug, stage)) healed.push(stage) + } + return healed +} diff --git a/packages/haiku/src/orchestrator/workflow/run-tick.ts b/packages/haiku/src/orchestrator/workflow/run-tick.ts index 6553c68e8..4b8daa882 100644 --- a/packages/haiku/src/orchestrator/workflow/run-tick.ts +++ b/packages/haiku/src/orchestrator/workflow/run-tick.ts @@ -54,6 +54,7 @@ import { import { completePendingFixChainMerges } from "./fix-chain-merge-gate.js" import { reconcileOrphanedHatSequences } from "./hat-sequence-migration.js" import { healDuplicateFeedbackIds } from "./heal-duplicate-feedback-ids.js" +import { healOptionalStageDivergence } from "./heal-optional-stage-divergence.js" import { purgeDeadSidecars } from "./purge-dead-sidecars.js" import { selfRepairMissingApprovals } from "./self-repair-approvals.js" import { @@ -365,6 +366,13 @@ export function runWorkflowTick( }) } + // Pre-tick self-heal: a pre-2026-05-28 buggy drop wrote an optional-stage + // removal to the stage branch but not intent main, so the cursor (reads + // main) keeps re-arriving at a stage the branches already dropped. Detect + // the divergence (cheap, no checkout) and propagate the drop up to main. + // Idempotent; no-op for healthy intents and in filesystem mode. + healOptionalStageDivergence(slug, studio) + const mode = (intentFm.mode as string) || "" if (!mode) { return broadcastTick(slug, { diff --git a/packages/haiku/src/orchestrator/workflow/side-effects.ts b/packages/haiku/src/orchestrator/workflow/side-effects.ts index 5b119d767..2dffbe7b2 100644 --- a/packages/haiku/src/orchestrator/workflow/side-effects.ts +++ b/packages/haiku/src/orchestrator/workflow/side-effects.ts @@ -51,6 +51,7 @@ import { mergeStageBranchForward, mergeStageBranchIntoMain, openStageDraftPullRequest, + providerFromOrigin, pushStageBranch, } from "../../git-worktree.js" import { withIntentMainLock } from "../../locks.js" @@ -114,7 +115,10 @@ function readFrontmatter(filePath: string): Record { * Mode shaping: `discrete` opens a PR for EVERY stage. `discrete-hybrid` * opens one ONLY for stages whose review gate is external (the others run * continuous and keep their work on the intent-main PR). */ -function openStageDraftPrIfDelivery(slug: string, stage: string): void { +async function openStageDraftPrIfDelivery( + slug: string, + stage: string, +): Promise { const fm = readFrontmatter(join(intentDir(slug), "intent.md")) const mode = (fm.mode as string) || "" if (!PER_STAGE_PR_MODES.has(mode)) return @@ -122,10 +126,15 @@ function openStageDraftPrIfDelivery(slug: string, stage: string): void { const studio = (fm.studio as string) || "" if (!studio || !stageRequiresExternalReview(studio, stage)) return } - if (!isGitRepo() || detectPrTool() === null) return + // Skip only when there's genuinely no way to open a PR: no `gh`/`glab` CLI + // AND no recognized provider remote. A provider remote alone is enough — + // openPullRequest authenticates when needed (REST over the stored or + // just-obtained token), so a stored token isn't a precondition. + if (!isGitRepo()) return + if (detectPrTool() === null && providerFromOrigin() === null) return if (readStagePr(slug, stage)?.url) return try { - const draft = openStageDraftPullRequest({ slug, stage }) + const draft = await openStageDraftPullRequest({ slug, stage }) if (draft.createdUrl) { setStagePrField(slug, stage, "url", draft.createdUrl) setStagePrField(slug, stage, "status", "draft") @@ -174,7 +183,10 @@ function findPreviousStage(slug: string, stage: string): string | undefined { * * The intent's `mode` field controls iteration cadence and review * rules but not branching topology — both modes branch per-stage. */ -export function workflowStartStage(slug: string, stage: string): void { +export async function workflowStartStage( + slug: string, + stage: string, +): Promise { createIntentBranch(slug) cleanupOrphanedStageBranches(slug) @@ -264,7 +276,7 @@ export function workflowStartStage(slug: string, stage: string): void { // Per-stage delivery PR (discrete / discrete-hybrid): open a draft for // this stage now that its branch exists, so proof + work land on it. // Stamps the stage_prs map; the gitCommitState below commits the stamp. - openStageDraftPrIfDelivery(slug, stage) + await openStageDraftPrIfDelivery(slug, stage) emitTelemetry("haiku.stage.started", { intent: slug, stage }) gitCommitState(`haiku: start stage ${stage}`) @@ -322,16 +334,16 @@ export function workflowCompleteStage( * completed branch between ticks (which would otherwise force the * next tick's `ensureOnStageBranch` guard onto intent main via an * auto-commit detour, stranding the advance). */ -export function workflowAdvanceStage( +export async function workflowAdvanceStage( slug: string, currentStage: string, nextStage: string, -): void { +): Promise { workflowCompleteStage(slug, currentStage, "advanced") // `active_stage` write removed 2026-05-12 — see workflowStartStage // for the rationale (cursor derives via findCurrentStage). - workflowStartStage(slug, nextStage) + await workflowStartStage(slug, nextStage) // Reseal: workflowCompleteStage sealed against active_stage=currentStage, // then workflowStartStage rewrote frontmatter again; the prior checksums are @@ -585,7 +597,7 @@ export function completeOrReviewIntent( /** Mark intent completed and fan the last stage (and any unmerged * prior stages) into intent main, checkout intent main, reap every * merged stage branch so the intent lands on a single clean ref. */ -export function workflowIntentComplete(slug: string): void { +export async function workflowIntentComplete(slug: string): Promise { const intentFile = join(intentDir(slug), "intent.md") if (existsSync(intentFile)) { // If we opened a draft PR at intent_create time, flip it to @@ -598,7 +610,7 @@ export function workflowIntentComplete(slug: string): void { const draftUrl = fmRaw.draft_pr_url as string | undefined const draftStatus = fmRaw.draft_pr_status as string | undefined if (draftUrl && draftStatus === "draft") { - const ready = markPullRequestReady(draftUrl) + const ready = await markPullRequestReady(draftUrl) if (ready.ok) { setFrontmatterField(intentFile, "draft_pr_status", "ready") setFrontmatterField(intentFile, "draft_pr_ready_at", timestamp()) diff --git a/packages/haiku/src/provider-rest.ts b/packages/haiku/src/provider-rest.ts new file mode 100644 index 000000000..3c1586c14 --- /dev/null +++ b/packages/haiku/src/provider-rest.ts @@ -0,0 +1,330 @@ +// provider-rest.ts — PR/MR write operations over the provider REST API, +// driven by a stored OAuth token (Phase 4 of provider OAuth). +// +// These are the token-backed counterparts to the `gh` / `glab` shell-outs in +// git-worktree.ts. When the engine has a stored provider token (captured by +// haiku_auth_login), it can create and ready a PR/MR without an authed CLI on +// PATH — the provider-agnostic delivery path. When there's no token, the +// callers fall back to the CLI as before; this module is never the only path. +// +// Scope is deliberately create + mark-ready. There is NO merge helper here — +// the engine never merges a delivery PR/MR on its own (the merge is the human's +// explicit call, and on hosts with branch protection the merge itself is the +// approval signal). Merge stays CLI/human-only by design. +// +// Every provider call is factored behind an injectable `fetch` so the handshake +// is unit-testable (dedup → create → ready) without real network. The default +// callers pass global fetch. NOTE: the REST contracts here are written to the +// GitHub / GitLab API docs but validated only against a mocked fetch — same as +// haiku_upload_proof. The CLI fallback remains the integration-proven path; the +// REST path is exercised for real once a broker token exists (post-deploy). + +import type { ProviderName } from "./state/schemas/global-settings.js" + +/** Everything a REST PR/MR call needs: the parsed remote + the bearer. */ +export interface PrRestContext { + provider: ProviderName + host: string + owner: string + repo: string + token: string +} + +/** Inputs for opening a PR/MR. */ +export interface CreatePrInput { + /** Head / source branch. */ + branch: string + /** Base / target branch. */ + mainline: string + title: string + body: string + draft: boolean +} + +/** Stable named errors so callers can fall back to the CLI on any REST miss. */ +export class ProviderRestError extends Error { + code: string + constructor(code: string, message: string) { + super(message) + this.code = code + } +} + +/** GitHub REST + GraphQL bases for a host. github.com → api.github.com; + * GitHub Enterprise → https:///api/v3 (REST) + /api/graphql. */ +function githubApiBase(host: string): { rest: string; graphql: string } { + if (host === "github.com") { + return { + rest: "https://api.github.com", + graphql: "https://api.github.com/graphql", + } + } + return { + rest: `https://${host}/api/v3`, + graphql: `https://${host}/api/graphql`, + } +} + +/** GitLab REST base for a host (always /api/v4). */ +function gitlabApiBase(host: string): string { + return `https://${host}/api/v4` +} + +function githubAuthHeaders(token: string): Record { + return { + authorization: `Bearer ${token}`, + accept: "application/vnd.github+json", + "x-github-api-version": "2022-11-28", + } +} + +/** GitLab accepts an OAuth access token (what the broker relays) via the + * Authorization: Bearer header. PATs would also work via PRIVATE-TOKEN, but + * the broker yields OAuth tokens, so Bearer is the correct choice. */ +function gitlabAuthHeaders(token: string): Record { + return { authorization: `Bearer ${token}` } +} + +// ── GitHub ───────────────────────────────────────────────────────── + +async function createPullRequestGitHub( + ctx: PrRestContext, + input: CreatePrInput, + fetchImpl: typeof fetch, +): Promise<{ url: string }> { + const { rest } = githubApiBase(ctx.host) + const repoPath = `${ctx.owner}/${ctx.repo}` + const headers = githubAuthHeaders(ctx.token) + + // dedup: an open PR for this head already exists → return it + const listRes = await fetchImpl( + `${rest}/repos/${repoPath}/pulls?head=${encodeURIComponent( + `${ctx.owner}:${input.branch}`, + )}&state=open&base=${encodeURIComponent(input.mainline)}`, + { headers }, + ) + if (listRes.ok) { + const existing = (await listRes.json()) as Array<{ html_url?: string }> + if (Array.isArray(existing) && existing[0]?.html_url) { + return { url: existing[0].html_url } + } + } + + const createRes = await fetchImpl(`${rest}/repos/${repoPath}/pulls`, { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ + title: input.title, + head: input.branch, + base: input.mainline, + body: input.body, + draft: input.draft, + }), + }) + if (!createRes.ok) { + throw new ProviderRestError( + "pr_create_github_failed", + `creating PR returned HTTP ${createRes.status}`, + ) + } + const created = (await createRes.json()) as { html_url?: string } + if (!created.html_url) { + throw new ProviderRestError( + "pr_create_github_no_url", + "GitHub create PR response had no html_url", + ) + } + return { url: created.html_url } +} + +/** Mark a GitHub PR ready for review. REST has no draft→ready endpoint — it's + * the GraphQL `markPullRequestReadyForReview` mutation, which needs the PR's + * node_id. So: parse the PR number from the URL, GET the PR for its node_id, + * then run the mutation. */ +async function markReadyGitHub( + ctx: PrRestContext, + url: string, + fetchImpl: typeof fetch, +): Promise { + const { rest, graphql } = githubApiBase(ctx.host) + const repoPath = `${ctx.owner}/${ctx.repo}` + const headers = githubAuthHeaders(ctx.token) + + const numMatch = url.match(/\/pull\/(\d+)/) + if (!numMatch) { + throw new ProviderRestError( + "pr_ready_github_bad_url", + `could not parse PR number from URL: ${url}`, + ) + } + const getRes = await fetchImpl( + `${rest}/repos/${repoPath}/pulls/${numMatch[1]}`, + { headers }, + ) + if (!getRes.ok) { + throw new ProviderRestError( + "pr_ready_github_lookup_failed", + `looking up PR returned HTTP ${getRes.status}`, + ) + } + const pr = (await getRes.json()) as { node_id?: string } + if (!pr.node_id) { + throw new ProviderRestError( + "pr_ready_github_no_node_id", + "PR lookup returned no node_id", + ) + } + const mutation = + "mutation($id:ID!){markPullRequestReadyForReview(input:{pullRequestId:$id}){pullRequest{id}}}" + const gqlRes = await fetchImpl(graphql, { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ query: mutation, variables: { id: pr.node_id } }), + }) + if (!gqlRes.ok) { + throw new ProviderRestError( + "pr_ready_github_failed", + `markReadyForReview returned HTTP ${gqlRes.status}`, + ) + } + const out = (await gqlRes.json()) as { errors?: unknown[] } + if (Array.isArray(out.errors) && out.errors.length > 0) { + throw new ProviderRestError( + "pr_ready_github_graphql_error", + `markReadyForReview returned GraphQL errors: ${JSON.stringify(out.errors)}`, + ) + } +} + +// ── GitLab ───────────────────────────────────────────────────────── + +const GITLAB_DRAFT_PREFIX = /^(draft:|wip:)\s*/i + +async function createMergeRequestGitLab( + ctx: PrRestContext, + input: CreatePrInput, + fetchImpl: typeof fetch, +): Promise<{ url: string }> { + const api = gitlabApiBase(ctx.host) + const projectId = encodeURIComponent(`${ctx.owner}/${ctx.repo}`) + const headers = gitlabAuthHeaders(ctx.token) + + // dedup: an opened MR for this source branch already exists → return it + const listRes = await fetchImpl( + `${api}/projects/${projectId}/merge_requests?source_branch=${encodeURIComponent( + input.branch, + )}&state=opened`, + { headers }, + ) + if (listRes.ok) { + const existing = (await listRes.json()) as Array<{ web_url?: string }> + if (Array.isArray(existing) && existing[0]?.web_url) { + return { url: existing[0].web_url } + } + } + + // GitLab marks a draft via a "Draft: " title prefix (what `glab --draft` does). + const title = input.draft ? `Draft: ${input.title}` : input.title + const createRes = await fetchImpl( + `${api}/projects/${projectId}/merge_requests`, + { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ + source_branch: input.branch, + target_branch: input.mainline, + title, + description: input.body, + }), + }, + ) + if (!createRes.ok) { + throw new ProviderRestError( + "mr_create_gitlab_failed", + `creating MR returned HTTP ${createRes.status}`, + ) + } + const created = (await createRes.json()) as { web_url?: string } + if (!created.web_url) { + throw new ProviderRestError( + "mr_create_gitlab_no_url", + "GitLab create MR response had no web_url", + ) + } + return { url: created.web_url } +} + +/** Mark a GitLab MR ready by stripping the "Draft:"/"WIP:" prefix from its + * title (the inverse of `glab mr update --ready`). Fetches the current title, + * strips the prefix, PUTs it back. No-op when already un-prefixed. */ +async function markReadyGitLab( + ctx: PrRestContext, + url: string, + fetchImpl: typeof fetch, +): Promise { + const api = gitlabApiBase(ctx.host) + const projectId = encodeURIComponent(`${ctx.owner}/${ctx.repo}`) + const headers = gitlabAuthHeaders(ctx.token) + + const iidMatch = url.match(/\/merge_requests\/(\d+)/) + if (!iidMatch) { + throw new ProviderRestError( + "mr_ready_gitlab_bad_url", + `could not parse MR iid from URL: ${url}`, + ) + } + const iid = iidMatch[1] + const getRes = await fetchImpl( + `${api}/projects/${projectId}/merge_requests/${iid}`, + { headers }, + ) + if (!getRes.ok) { + throw new ProviderRestError( + "mr_ready_gitlab_lookup_failed", + `looking up MR returned HTTP ${getRes.status}`, + ) + } + const mr = (await getRes.json()) as { title?: string } + const stripped = (mr.title ?? "").replace(GITLAB_DRAFT_PREFIX, "") + if (stripped === mr.title) return // already ready + const putRes = await fetchImpl( + `${api}/projects/${projectId}/merge_requests/${iid}`, + { + method: "PUT", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ title: stripped }), + }, + ) + if (!putRes.ok) { + throw new ProviderRestError( + "mr_ready_gitlab_failed", + `updating MR returned HTTP ${putRes.status}`, + ) + } +} + +// ── Routing ──────────────────────────────────────────────────────── + +/** Open a PR/MR over the provider REST API. Routes by provider. */ +export async function createPullRequestRest( + ctx: PrRestContext, + input: CreatePrInput, + fetchImpl: typeof fetch, +): Promise<{ url: string }> { + if (ctx.provider === "github") { + return createPullRequestGitHub(ctx, input, fetchImpl) + } + return createMergeRequestGitLab(ctx, input, fetchImpl) +} + +/** Mark a draft PR/MR ready for review over the provider REST API. */ +export async function markPullRequestReadyRest( + ctx: PrRestContext, + url: string, + fetchImpl: typeof fetch, +): Promise { + if (ctx.provider === "github") { + return markReadyGitHub(ctx, url, fetchImpl) + } + return markReadyGitLab(ctx, url, fetchImpl) +} diff --git a/packages/haiku/src/state-tools.ts b/packages/haiku/src/state-tools.ts index d6603dc7c..37ab7c6e5 100644 --- a/packages/haiku/src/state-tools.ts +++ b/packages/haiku/src/state-tools.ts @@ -32,6 +32,7 @@ import { classifyGateRun } from "./gate-environment.js" import { buildFbHatDispatchBlock } from "./orchestrator/fb-dispatch-builder.js" import { resolveRejectTarget } from "./orchestrator/hat-loop-routing.js" import { + resolveActiveStageWithFallback, resolveIntentStages, resolveStageFixHats, resolveStudioFixHats, @@ -86,7 +87,7 @@ import { listOrphanDiscreteIntents, mergeFixChainWorktree, mergeUnitWorktree, - openPullRequest, + openPullRequestCli, pushFixChainWorktree, pushUnitWorktree, readFileFromBranch, @@ -1585,7 +1586,7 @@ function repairAllBranches(autoApply: boolean): { summary.pushError = push.pushError if (push.committed && push.pushed && wasAlreadyMerged) { summary.merged = true - const prResult = openPullRequest( + const prResult = openPullRequestCli( branch, mainline, `repair: metadata fixes for ${slug}`, @@ -1698,7 +1699,7 @@ function repairArchivedOnMainline( summary.pushError = push.pushError if (push.committed && push.pushed) { - const prResult = openPullRequest( + const prResult = openPullRequestCli( repairBranch, mainline, "repair: metadata fixes for archived intents", @@ -5019,11 +5020,12 @@ function injectPushWarning( /** Resolve the active stage for an intent from its frontmatter */ function resolveActiveStage(intent: string): string { - const root = findHaikuRoot() - const intentFile = join(root, "intents", intent, "intent.md") - if (!existsSync(intentFile)) return "" - const { data } = parseFrontmatter(readFileSync(intentFile, "utf8")) - return (data.active_stage as string) || "" + // Delegate to the shared fallback resolver (stamp → derived-from-canonical- + // main → last plan stage) so a diverged or unstamped intent still resolves + // a stage and never collapses to "". studio.ts ↔ state-tools is a call-time + // ESM cycle (each only uses the other inside function bodies), so the static + // import is safe. + return resolveActiveStageWithFallback(intent) } /** @@ -5094,6 +5096,49 @@ function enforceStageBranch( return null } +/** + * Align the checkout to the branch the ENGINE READS for a MANUAL feedback + * mutation (reject / delete) — the ACTIVE stage branch, NOT the finding's own + * (possibly earlier, already-completed) stage branch. + * + * The cursor walks every stage `0..active` on the ACTIVE stage branch's tree; + * an earlier stage's feedback dir is present there (inherited via the + * main→active downstream sync). So a manual mutation of an earlier-stage + * finding must land on the ACTIVE branch to be visible on the next tick — + * `enforceStageBranch(intent, fbStage)` would instead switch to the finding's + * own branch, stranding the mutation where the engine never reads it (and, when + * that branch doesn't even carry the file, failing to find it at all). This is + * the merge-trains-integration-gate failure-mode 3 (2026-05-28): a reject of a + * completed-stage finding done while a later stage was active appeared to "not + * take." Intent-scope findings (no stage) align to intent main as before. + * + * The fix-loop path does NOT use this — there the cursor has already rewound to + * the finding's stage (it IS the active stage), so `fbStage === active` and the + * old alignment is already correct. + */ +function enforceFeedbackBranch( + intent: string, + fbStage: string | undefined, +): { content: Array<{ type: "text"; text: string }>; isError: true } | null { + if (!fbStage) return enforceStageBranch(intent, undefined) + let activeStage: string | null = null + try { + const intentMd = join(intentDir(intent), "intent.md") + if (existsSync(intentMd)) { + const studio = + (parseFrontmatter(readFileSync(intentMd, "utf8")).data + .studio as string) || "" + if (studio) activeStage = findCurrentStage(intent, studio) + } + } catch { + // Fall through: unresolved active stage → intent main (null below). + } + // Active stage drives the read branch. null (intent-completion phase, or + // unresolved) → intent main. When the active stage IS the finding's stage + // this is identical to the old `enforceStageBranch(intent, fbStage)`. + return enforceStageBranch(intent, activeStage ?? undefined) +} + /** * Find a unit file by searching through stages. Returns { path, stage } * or null. @@ -8679,7 +8724,7 @@ Frontmatter is workflow engine-controlled and cannot be set through this tool. F • workflow-driven (mutated over the FB lifecycle): ${FSM_DRIVEN_FB_FIELDS.join(", ")} • Set at creation, immutable thereafter: ${CREATE_TIME_FB_FIELDS.join(", ")} -Use haiku_feedback_update for status transitions and haiku_feedback_reject for rejections.`, +Status transitions are engine-driven — closure runs through the fix-loop's terminal hat (haiku_feedback_advance_hat), not a manual update. Use haiku_feedback_reject to mark a finding invalid/stale (it stamps rejected_at, so the open-feedback walk treats it as terminal and stops re-dispatching it).`, inputSchema: jsonSchemaOf(HAIKU_FEEDBACK_WRITE_INPUT_SCHEMA), outputSchema: { type: "object", @@ -12898,7 +12943,10 @@ export function handleStateTool( isError: true, } - const feedbackDeleteBranchErr = enforceStageBranch( + // Align to the engine's READ branch (active stage), not the + // finding's own stage branch — so a manual delete of an + // earlier-stage finding lands where the next tick reads it (FM3). + const feedbackDeleteBranchErr = enforceFeedbackBranch( intent, stage || undefined, ) @@ -13103,9 +13151,13 @@ export function handleStateTool( // Enforce branch BEFORE reading the feedback file — if main has // drifted ahead, the file may only exist on the stage branch. - // Reading first would spuriously report "not found". Intent- - // scope ("") resolves to intent-main via ensureOnStageBranch. - const feedbackRejectBranchErr = enforceStageBranch( + // Reading first would spuriously report "not found". Align to the + // engine's READ branch (the ACTIVE stage), not the finding's own + // stage branch: an earlier-stage finding lives on the active + // branch's tree, and a reject must land there to be visible next + // tick (FM3 — the merge-trains-integration-gate stranded reject). + // Intent-scope ("") resolves to intent main. + const feedbackRejectBranchErr = enforceFeedbackBranch( intent, stage || undefined, ) diff --git a/packages/haiku/src/state/schemas/global-settings.ts b/packages/haiku/src/state/schemas/global-settings.ts new file mode 100644 index 000000000..2ebfcd46a --- /dev/null +++ b/packages/haiku/src/state/schemas/global-settings.ts @@ -0,0 +1,90 @@ +// state/schemas/global-settings.ts — TypeBox + AJV schema for the GLOBAL +// (user-level) `~/.haiku/settings.json` file. Per the schema-definitions rule, +// every shape that crosses a process boundary (here: on-disk global settings, +// read by the engine's MR/PR + proof-upload paths) gets a real runtime-checked +// TypeBox schema yielding both the JSONSchema validator AND the TS type. +// +// Shape: +// { providers?: { github?: ProviderToken, gitlab?: ProviderToken } } +// +// ProviderToken is what the haikumethod.ai auth broker relays back: the OAuth +// access token (+ optional refresh token / expiry for the device-flow case), +// the granted scopes, the resolved account login, the provider host (so +// enterprise/self-managed hosts are matched by value, not assumed), and an +// obtained-at stamp. + +import { type Static, Type } from "@sinclair/typebox" +import { stateAjv } from "./_ajv.js" + +/** The providers we broker auth for. The MCP picks one by the repo's origin + * host (github.com → github; gitlab.com / self-managed → gitlab). */ +export const PROVIDER_NAMES = ["github", "gitlab"] as const +export type ProviderName = (typeof PROVIDER_NAMES)[number] + +export const PROVIDER_TOKEN_SCHEMA = Type.Object( + { + access_token: Type.String({ + minLength: 1, + description: "OAuth access token used as the provider API bearer.", + }), + refresh_token: Type.Optional( + Type.String({ + description: + "Refresh token (device-flow only). Absent for a pasted PAT. Used by the MCP to re-run the broker /refresh exchange.", + }), + ), + expires_at: Type.Optional( + Type.String({ + description: + "ISO-8601 expiry of access_token. Absent = non-expiring (PAT-style). Drives the `expired` flag in haiku_auth_status.", + }), + ), + scopes: Type.Optional( + Type.Array(Type.String(), { + description: "Granted OAuth scopes (e.g. ['repo'] / ['api']).", + }), + ), + account: Type.Optional( + Type.String({ + description: "Resolved provider account login the token belongs to.", + }), + ), + host: Type.String({ + minLength: 1, + description: + "Provider host (github.com, gitlab.com, or an enterprise/self-managed host). Matched against the repo origin host at use time.", + }), + obtained_at: Type.String({ + minLength: 1, + description: "ISO-8601 timestamp the token was relayed + stored.", + }), + }, + { additionalProperties: false }, +) +export type ProviderToken = Static + +export const GLOBAL_SETTINGS_SCHEMA = Type.Object( + { + providers: Type.Optional( + Type.Object( + { + github: Type.Optional(PROVIDER_TOKEN_SCHEMA), + gitlab: Type.Optional(PROVIDER_TOKEN_SCHEMA), + }, + { additionalProperties: false }, + ), + ), + }, + // Permissive at the TOP level only: a future unrelated global-settings key + // (not under our control) must not invalidate the whole file and orphan the + // tokens. The providers sub-objects stay strict (additionalProperties:false). + { additionalProperties: true }, +) +export type GlobalSettings = Static + +export const validateProviderTokenSchema = stateAjv.compile( + PROVIDER_TOKEN_SCHEMA, +) +export const validateGlobalSettingsSchema = stateAjv.compile( + GLOBAL_SETTINGS_SCHEMA, +) diff --git a/packages/haiku/src/state/schemas/index.ts b/packages/haiku/src/state/schemas/index.ts index 5e596994c..c380788f3 100644 --- a/packages/haiku/src/state/schemas/index.ts +++ b/packages/haiku/src/state/schemas/index.ts @@ -58,6 +58,32 @@ export { isFixBlockingSeverity, validateHaikuFeedbackInputSchema, } from "./feedback.js" +export type { + GlobalSettings, + ProviderName, + ProviderToken, +} from "./global-settings.js" +export { + GLOBAL_SETTINGS_SCHEMA, + PROVIDER_NAMES, + PROVIDER_TOKEN_SCHEMA, + validateGlobalSettingsSchema, + validateProviderTokenSchema, +} from "./global-settings.js" +export { + HAIKU_AUTH_LOGIN_INPUT_SCHEMA, + HAIKU_AUTH_LOGOUT_INPUT_SCHEMA, + HAIKU_AUTH_STATUS_INPUT_SCHEMA, + HAIKU_UPLOAD_PROOF_INPUT_SCHEMA, + type HaikuAuthLoginInput, + type HaikuAuthLogoutInput, + type HaikuAuthStatusInput, + type HaikuUploadProofInput, + validateHaikuAuthLoginInputSchema, + validateHaikuAuthLogoutInputSchema, + validateHaikuAuthStatusInputSchema, + validateHaikuUploadProofInputSchema, +} from "./inputs/auth-tools.js" export type { HaikuAwaitDesignDirectionInput, HaikuAwaitGateInput, @@ -221,6 +247,7 @@ export type { HaikuStageElaborationSealInput, HaikuStageGetInput, HaikuStageSetInput, + HaikuWriteBriefInput, } from "./inputs/stages.js" export { HAIKU_INTENT_SEAL_INPUT_SCHEMA, @@ -229,12 +256,14 @@ export { HAIKU_STAGE_ELABORATION_SEAL_INPUT_SCHEMA, HAIKU_STAGE_GET_INPUT_SCHEMA, HAIKU_STAGE_SET_INPUT_SCHEMA, + HAIKU_WRITE_BRIEF_INPUT_SCHEMA, validateHaikuIntentSealInputSchema, validateHaikuStageDecomposeSealInputSchema, validateHaikuStageElaborationRecordInputSchema, validateHaikuStageElaborationSealInputSchema, validateHaikuStageGetInputSchema, validateHaikuStageSetInputSchema, + validateHaikuWriteBriefInputSchema, } from "./inputs/stages.js" export type { HaikuReadDiscoveryInput, diff --git a/packages/haiku/src/state/schemas/inputs/auth-tools.ts b/packages/haiku/src/state/schemas/inputs/auth-tools.ts new file mode 100644 index 000000000..2b9bab07c --- /dev/null +++ b/packages/haiku/src/state/schemas/inputs/auth-tools.ts @@ -0,0 +1,117 @@ +// state/schemas/inputs/auth-tools.ts — TypeBox input schemas for the +// provider-auth MCP tools (haiku_auth_status, haiku_auth_logout, +// haiku_auth_login, haiku_upload_proof) over the global token store +// (~/.haiku/settings.json). Phase 1 is the read/clear surface; Phase 3 adds +// the broker login handshake; Phase 5 adds provider proof upload. +// +// Per the schema-definitions rule: three exports per tool (schema, Static<> +// type, compiled validator), additionalProperties:false, stable named error +// code `_input_invalid` produced by validateToolInput on miss. + +import { type Static, Type } from "@sinclair/typebox" +import { stateAjv } from "../_ajv.js" +import { PROVIDER_NAMES } from "../global-settings.js" + +// ── haiku_auth_status ────────────────────────────────────────────── +// +// Report connected providers (account / scopes / host / expiry / expired). +// `provider` optionally narrows to one; omitted = all. Never returns token +// values. Empty object is valid (status of everything). +export const HAIKU_AUTH_STATUS_INPUT_SCHEMA = Type.Object( + { + provider: Type.Optional( + Type.String({ + enum: [...PROVIDER_NAMES], + description: + "Narrow the status to one provider (github | gitlab). Omit for all.", + }), + ), + }, + { additionalProperties: false }, +) +export type HaikuAuthStatusInput = Static +export const validateHaikuAuthStatusInputSchema = stateAjv.compile( + HAIKU_AUTH_STATUS_INPUT_SCHEMA, +) + +// ── haiku_auth_logout ────────────────────────────────────────────── +// +// Clear one provider's stored token. `provider` is required (logging out of +// "everything" should be an explicit per-provider call, not an accident). +export const HAIKU_AUTH_LOGOUT_INPUT_SCHEMA = Type.Object( + { + provider: Type.String({ + enum: [...PROVIDER_NAMES], + description: "Provider to disconnect (github | gitlab). Required.", + }), + }, + { additionalProperties: false }, +) +export type HaikuAuthLogoutInput = Static +export const validateHaikuAuthLogoutInputSchema = stateAjv.compile( + HAIKU_AUTH_LOGOUT_INPUT_SCHEMA, +) + +// ── haiku_auth_login ─────────────────────────────────────────────── +// +// Run the haikumethod.ai broker handshake to capture a provider OAuth token +// into the global store. `provider` is OPTIONAL: when omitted, the tool infers +// it from the repo's `origin` host (github.com → github, gitlab host → gitlab). +// No token is ever echoed back; the tool returns only ok / provider / account. +export const HAIKU_AUTH_LOGIN_INPUT_SCHEMA = Type.Object( + { + provider: Type.Optional( + Type.String({ + enum: [...PROVIDER_NAMES], + description: + "Provider to authenticate (github | gitlab). Omit to infer from the repo's origin remote host.", + }), + ), + }, + { additionalProperties: false }, +) +export type HaikuAuthLoginInput = Static +export const validateHaikuAuthLoginInputSchema = stateAjv.compile( + HAIKU_AUTH_LOGIN_INPUT_SCHEMA, +) + +// ── haiku_upload_proof ───────────────────────────────────────────── +// +// Upload a runtime-verification proof file to the intent's / stage's change +// request (GitHub release asset; GitLab project-uploads API + MR reference). +// Provider is detected from the origin host. `path` is required (the proof file +// on disk); `pr_url` optionally targets a specific PR/MR; `stage` scopes the +// proof to a stage's delivery PR when present. +export const HAIKU_UPLOAD_PROOF_INPUT_SCHEMA = Type.Object( + { + intent: Type.String({ + minLength: 1, + description: "Intent slug the proof belongs to.", + }), + stage: Type.Optional( + Type.String({ + minLength: 1, + description: + "Stage whose delivery PR the proof attaches to. Omit for the intent-main PR.", + }), + ), + pr_url: Type.Optional( + Type.String({ + minLength: 1, + description: + "Explicit PR/MR URL to upload against. Omit to use the intent/stage default.", + }), + ), + path: Type.String({ + minLength: 1, + description: "Filesystem path to the proof file to upload.", + }), + }, + { additionalProperties: false }, +) +export type HaikuUploadProofInput = Static< + typeof HAIKU_UPLOAD_PROOF_INPUT_SCHEMA +> +export const validateHaikuUploadProofInputSchema = stateAjv.compile( + HAIKU_UPLOAD_PROOF_INPUT_SCHEMA, +) diff --git a/packages/haiku/src/state/schemas/inputs/stages.ts b/packages/haiku/src/state/schemas/inputs/stages.ts index 242a0cf7c..17dfe03db 100644 --- a/packages/haiku/src/state/schemas/inputs/stages.ts +++ b/packages/haiku/src/state/schemas/inputs/stages.ts @@ -6,6 +6,30 @@ import { stateAjv } from "../_ajv.js" const stateFile = Type.Optional(Type.String()) +// ── haiku_write_brief ─────────────────────────────────────────────── +// +// Writes the current stage's user-facing BRIEF.md. The agent supplies ONLY +// the prose body — everything else is engine-owned: the intent resolves from +// the current branch (or sole active intent in filesystem mode), the stage +// from the cursor's position, and the `phase:` frontmatter from whether +// BRIEF.md already exists (absent → pre, present → post). This tool is only +// ever called in-flow, so the engine always knows where it is. + +export const HAIKU_WRITE_BRIEF_INPUT_SCHEMA = Type.Object( + { + body: Type.String({ + minLength: 1, + description: + "Markdown body of the brief (human-facing prose, no frontmatter). The engine resolves the intent + stage from the current cursor position and stamps the phase frontmatter; do not include a `---` block, an intent, or a stage yourself.", + }), + }, + { additionalProperties: false }, +) +export type HaikuWriteBriefInput = Static +export const validateHaikuWriteBriefInputSchema = stateAjv.compile( + HAIKU_WRITE_BRIEF_INPUT_SCHEMA, +) + // ── haiku_stage_elaboration_record ────────────────────────────────── // // Captures the per-stage human-conversation outcome on disk at diff --git a/packages/haiku/src/statusline/index.ts b/packages/haiku/src/statusline/index.ts index 9e6514922..440e44e84 100644 --- a/packages/haiku/src/statusline/index.ts +++ b/packages/haiku/src/statusline/index.ts @@ -313,7 +313,22 @@ function install(args: string[]): void { ) } - settings.statusLine = { type: "command", command: cmd, padding: 0 } + // `refreshInterval: 1` re-runs the line every second ON TOP OF Claude + // Code's event-driven updates. Event triggers (new assistant message, mode + // change, …) go quiet exactly when the H·AI·K·U engine is busiest: while the + // main agent waits on background subagents (a hat wave, a fix loop, discovery + // fan-out), the tick advances + rewrites the statusline snapshot but NO event + // fires, so a purely event-driven line freezes mid-wave. The 1s timer keeps + // the pipeline/phase/pool bars walking the cursor in near-real-time during + // those idle-but-working stretches. 1 is the documented minimum; the renderer + // is pure + cheap (on-disk FM reads, no network), so the per-tick cost is + // negligible. + settings.statusLine = { + type: "command", + command: cmd, + padding: 0, + refreshInterval: 1, + } mkdirSync(dirname(settingsPath), { recursive: true }) writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`) console.error( diff --git a/packages/haiku/src/statusline/links.ts b/packages/haiku/src/statusline/links.ts new file mode 100644 index 000000000..bb30c2605 --- /dev/null +++ b/packages/haiku/src/statusline/links.ts @@ -0,0 +1,99 @@ +// statusline/links.ts — build haikumethod.ai deep-link URLs for the +// clickable status line (OSC 8 hyperlinks, see render.ts `osc8`). +// +// URL formats mirror the website's real routes: +// • DEFINITION links (studio, stage) — static site routes describing the +// lifecycle template itself: +// studio → /studios// +// stage → /studios//stages// +// • INSTANCE links (intent, unit, feedback) — the live work in THIS repo, +// served by the browse SPA, which is path-keyed on the repo's origin +// host/owner/repo and uses KEYWORD-delimited segments (see +// website/lib/browse/url.ts `buildBrowseUrl`): +// intent → /browse////intent// +// unit → …/intent//stage//unit// +// feedback → …/intent//stage//feedback// (stage-scoped) +// …/intent//feedback// (intent-scoped) +// These need the repo remote, so they return null when there's no +// parseable `origin` (a local-only repo isn't browseable — the chip just +// renders unlinked). +// +// Base host overridable via HAIKU_WEB_BASE (default https://haikumethod.ai). + +const DEFAULT_BASE = "https://haikumethod.ai" + +/** Site base, trailing slash stripped. Honors HAIKU_WEB_BASE. */ +function webBase(): string { + const raw = process.env.HAIKU_WEB_BASE?.trim() + return (raw && raw.length > 0 ? raw : DEFAULT_BASE).replace(/\/+$/, "") +} + +/** Encode a single path segment (slugs/stage/unit/feedback ids are simple + * identifiers, but encode defensively against spaces / unexpected chars). */ +function seg(s: string): string { + return encodeURIComponent(s) +} + +/** Studio DEFINITION page. */ +export function studioDefUrl(studio: string): string | null { + if (!studio) return null + return `${webBase()}/studios/${seg(studio)}/` +} + +/** Stage DEFINITION page within a studio. */ +export function stageDefUrl(studio: string, stage: string): string | null { + if (!studio || !stage) return null + return `${webBase()}/studios/${seg(studio)}/stages/${seg(stage)}/` +} + +/** Repo coordinates for the browse SPA's path-based deep links. */ +export interface RepoCoords { + host: string + owner: string + repo: string +} + +/** The `/browse///` project prefix, or null when any + * coord is missing. Repo may itself contain slashes (GitLab subgroups), so + * it's emitted as-is (already a path); host/owner are single segments. */ +function browseProjectPrefix(repo: RepoCoords | null): string | null { + if (!repo?.host || !repo.owner || !repo.repo) return null + const repoPath = repo.repo.split("/").map(seg).join("/") + return `${webBase()}/browse/${seg(repo.host)}/${seg(repo.owner)}/${repoPath}` +} + +/** Intent browse page. Null when the repo has no browseable origin. */ +export function intentBrowseUrl( + repo: RepoCoords | null, + slug: string, +): string | null { + const prefix = browseProjectPrefix(repo) + if (!prefix || !slug) return null + return `${prefix}/intent/${seg(slug)}/` +} + +/** Unit deep link: intent → stage → unit (keyword-delimited). */ +export function unitBrowseUrl( + repo: RepoCoords | null, + slug: string, + stage: string, + unit: string, +): string | null { + const base = intentBrowseUrl(repo, slug) + if (!base || !stage || !unit) return null + return `${base}stage/${seg(stage)}/unit/${seg(unit)}/` +} + +/** Feedback deep link. Stage-scoped (`…/stage//feedback//`) or + * intent-scoped (`…/feedback//`) depending on whether `stage` is set. */ +export function feedbackBrowseUrl( + repo: RepoCoords | null, + slug: string, + stage: string, + feedbackId: string, +): string | null { + const base = intentBrowseUrl(repo, slug) + if (!base || !feedbackId) return null + const scope = stage ? `stage/${seg(stage)}/` : "" + return `${base}${scope}feedback/${seg(feedbackId)}/` +} diff --git a/packages/haiku/src/statusline/render.ts b/packages/haiku/src/statusline/render.ts index 8b09b462c..fd91c785a 100644 --- a/packages/haiku/src/statusline/render.ts +++ b/packages/haiku/src/statusline/render.ts @@ -46,6 +46,10 @@ export type StatuslinePhaseKind = export interface StatuslineStageDot { name: string status: "done" | "active" | "pending" + /** Optional OSC 8 target — the stage DEFINITION page. When set, the + * stage's hexagon (and, for the active stage, its name word) becomes a + * clickable link to haikumethod.ai/studios//stages/. */ + url?: string } /** Per-hat status within a unit/feedback progress bar. `done` = the hat @@ -57,9 +61,15 @@ export type HatSegment = "done" | "active" | "rejected" | "pending" export interface StatuslineState { /** Intent slug. */ intent: string + /** Optional OSC 8 target for the intent word — the intent browse page. + * Null/absent when the repo has no browseable origin. */ + intentUrl?: string /** Studio (lifecycle template) name — rendered as a dim tag. Empty * before studio selection. */ studio: string + /** Optional OSC 8 target for the studio tag — the studio DEFINITION + * page on haikumethod.ai. */ + studioUrl?: string /** Ordered stage pipeline with per-stage status. Empty for * intent-level phases that precede stage resolution. */ stages: StatuslineStageDot[] @@ -113,6 +123,9 @@ export interface StatuslineState { id: string segments: HatSegment[] severity?: "blocker" | "high" | "medium" | "low" | null + /** Optional OSC 8 target for the chip — the unit or feedback deep + * link in the browse SPA. Null/absent → chip renders unlinked. */ + url?: string }> | null /** Per-agent status chips for the SECOND line during the await phases * (pre-execute review, post-execute approval, quality gates, the @@ -294,6 +307,21 @@ function phaseColor(kind: StatuslinePhaseKind, gated: boolean): string { return C[kind] ?? C.execute } +// OSC 8 hyperlink: `ESC ] 8 ; ; URL BEL TEXT ESC ] 8 ; ; BEL`. A terminal +// that supports hyperlinks (iTerm2, Kitty, WezTerm, Ghostty, …) makes TEXT +// Cmd/Ctrl-clickable; one that doesn't simply renders TEXT and ignores the +// wrapper. It's NOT an SGR color code, so it's emitted regardless of +// NO_COLOR — links and color are orthogonal. `url` empty/undefined → TEXT +// unchanged (no wrapper), so an unbrowseable repo / pre-studio line just +// renders plain. The URL is intentionally NOT escaped here: callers build it +// with encodeURIComponent on each path segment (links.ts), and OSC 8 +// terminates the URL on BEL (`\x07`), which can't appear in a percent-encoded +// URL. BEL is the widely-supported terminator (ST `\x1b\\` also works). +function osc8(url: string | undefined, text: string): string { + if (!url) return text + return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07` +} + /** Render the status line. When `color` is false (or NO_COLOR is set), * emit the same glyphs with no escape codes. */ export function renderStatusline( @@ -310,10 +338,13 @@ export function renderStatusline( // one mark. const wordmark = paint(C.brand, WORDMARK) const brand = paint(C.brand, BRAND) - const intent = paint(C.intent, state.intent) + // The intent word links to its browse page (when the repo is browseable). + const intent = osc8(state.intentUrl, paint(C.intent, state.intent)) - // ── studio tag (dim) ── - const studio = state.studio ? paint(C.dim, state.studio) : "" + // ── studio tag (dim) ── links to the studio DEFINITION page. + const studio = state.studio + ? osc8(state.studioUrl, paint(C.dim, state.studio)) + : "" const phaseHue = phaseColor(state.phaseKind, state.gated) @@ -336,21 +367,29 @@ export function renderStatusline( // ── group 2: pipeline + stage/intent + phase bar + flow + phase ── const dots = state.stages .map((s) => { - if (s.status === "done") return paint(C.done, HEX_DONE) - if (s.status === "active") { + // Each hexagon links to its stage DEFINITION page (when known). + let glyph: string + if (s.status === "done") glyph = paint(C.done, HEX_DONE) + else if (s.status === "active") // The active hexagon carries the PHASE hue so the active dot // and the phase word read as one signal. - return paint(phaseHue, HEX_ACTIVE) - } - return paint(C.pending, HEX_PENDING) + glyph = paint(phaseHue, HEX_ACTIVE) + else glyph = paint(C.pending, HEX_PENDING) + return osc8(s.url, glyph) }) .join("") // Scope label in the stage slot. A stage-scoped line names the stage // (`development`); an intent-level line (setup phases, intent review, // sealing — no active stage) names the scope `intent`, so the // structure stays uniform: ` `. + // The active-stage word links to its stage DEFINITION page (same target + // as its hexagon). Resolve the URL from the matching pipeline dot. The + // intent-scope fallback ("intent") carries no stage def, so it stays plain. + const activeStageUrl = state.activeStage + ? state.stages.find((s) => s.name === state.activeStage)?.url + : undefined const stageName = state.activeStage - ? paint(C.stage, state.activeStage) + ? osc8(activeStageUrl, paint(C.stage, state.activeStage)) : paint(C.stage, "intent") const flowGlyph = state.gated ? GATED : FLOW const flow = paint(state.gated ? C.gate : C.dim, flowGlyph) @@ -407,6 +446,7 @@ export function renderStatusline( id: string segments: HatSegment[] severity?: "blocker" | "high" | "medium" | "low" | null + url?: string }): string => { // Resolve the chip's box + pip palette from severity. `undefined` = a // unit bar (default near-white box, default pips); `null` = an @@ -425,13 +465,15 @@ export function renderStatusline( .map((s) => (s === "pending" ? PIP_PENDING : PIP_DONE)) .join("") const mark = sev ? `${sev.mark} ` : "" - return `${mark}${it.id} ${bar}` + return osc8(it.url, `${mark}${it.id} ${bar}`) } const pips = it.segments .map((s) => `${pal[s]}${s === "pending" ? PIP_PENDING : PIP_DONE}`) .join("") const chipBg = sev ? sev.bg : C.chipBg - return `${chipBg} ${C.chipLabel}${it.id} ${pips} ${C.reset}` + // Whole chip (box + label + pips) is the click target — the unit or + // feedback deep link. + return osc8(it.url, `${chipBg} ${C.chipLabel}${it.id} ${pips} ${C.reset}`) } // An agent chip: a solid pastel status box (no bar). The box color IS diff --git a/packages/haiku/src/statusline/state.ts b/packages/haiku/src/statusline/state.ts index d4df3c802..c901dac65 100644 --- a/packages/haiku/src/statusline/state.ts +++ b/packages/haiku/src/statusline/state.ts @@ -12,6 +12,7 @@ import { execFileSync } from "node:child_process" import { existsSync, readdirSync, readFileSync, statSync } from "node:fs" import { join } from "node:path" +import { parseGitRemote } from "../git-worktree.js" import { resolveIntentStages, resolveStageFixHats, @@ -55,6 +56,14 @@ import { parseFrontmatter, } from "../state-tools.js" import { readStageArtifactDefs } from "../studio-reader.js" +import { + feedbackBrowseUrl, + intentBrowseUrl, + type RepoCoords, + stageDefUrl, + studioDefUrl, + unitBrowseUrl, +} from "./links.js" import type { HatSegment, StatuslinePhaseKind, @@ -84,6 +93,21 @@ function currentBranch(): string { } } +/** Best-effort repo coordinates from `origin` for the browse deep links. + * Null when there's no origin / it can't be parsed (a local-only repo + * isn't browseable on the website, so instance links are omitted). */ +function repoCoords(): RepoCoords | null { + try { + const origin = execFileSync("git", ["remote", "get-url", "origin"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim() + return parseGitRemote(origin) + } catch { + return null + } +} + /** Pick the active intent slug for the current tree. Priority: * 1. Git branch `haiku//<...>` → that slug (the branch IS the * "which intent am I on" signal). @@ -352,6 +376,9 @@ type ItemBar = { * classifier hasn't ranked it yet). Drives the leading severity glyph * and the highest-first bar order, mirroring fix-loop dispatch. */ severity?: FeedbackSeverity | null + /** OSC 8 deep link for the chip — the unit (execute) or feedback + * (fix-loop) browse URL. Undefined when the repo isn't browseable. */ + url?: string } type AgentChip = { id: string @@ -447,7 +474,13 @@ export function hatSegments( * the whole wave, not just the units that happen to be mid-flight. Wave * membership reuses the cursor's own wave computation, so it matches the * real pool and the `wave N/M` aggregate. */ -function unitBars(studio: string, stage: string, iDir: string): ItemBar[] { +function unitBars( + studio: string, + stage: string, + iDir: string, + repo: RepoCoords | null, + slug: string, +): ItemBar[] { const unitsDir = join(iDir, "stages", stage, "units") if (!existsSync(unitsDir)) return [] const hats = resolveStageHats(studio, stage) @@ -483,9 +516,11 @@ function unitBars(studio: string, stage: string, iDir: string): ItemBar[] { (typeof fm.started_at === "string" && (fm.started_at as string).length > 0) || iters.length > 0 + const unitName = f.replace(/\.md$/, "") out.push({ id: `U-${fileNumber(f)}`, segments: hatSegments(iters, hats, started), + url: unitBrowseUrl(repo, slug, stage, unitName) ?? undefined, }) } return out @@ -495,7 +530,13 @@ function unitBars(studio: string, stage: string, iDir: string): ItemBar[] { * given fix-hat sequence. Closed/rejected FBs are excluded. A * zero-iteration (queued) FB reads as an empty bar; a dispatched one * fills to its current fix-hat. */ -function feedbackBars(dir: string, fixHats: string[]): ItemBar[] { +function feedbackBars( + dir: string, + fixHats: string[], + repo: RepoCoords | null, + slug: string, + stage: string, +): ItemBar[] { if (!existsSync(dir) || fixHats.length === 0) return [] const out: ItemBar[] = [] for (const f of readdirSync(dir) @@ -521,10 +562,12 @@ function feedbackBars(dir: string, fixHats: string[]): ItemBar[] { // started_at on feedback (deriveFeedbackStatus: iterations[] non-empty // → "fixing"). A zero-iteration FB is queued, so it shows empty // progress (no in-progress pip) until the fix loop dispatches it. + const fbId = `FB-${fileNumber(f)}` out.push({ - id: `FB-${fileNumber(f)}`, + id: fbId, segments: hatSegments(iters, fixHats, iters.length > 0), severity, + url: feedbackBrowseUrl(repo, slug, stage, fbId) ?? undefined, }) } return out @@ -549,6 +592,14 @@ export function resolveStatuslineState(): StatuslineState | null { if (intentFm.composite) return null // composite intents aren't single-walk const studio = typeof intentFm.studio === "string" ? intentFm.studio : "" + // Repo coords (from origin) drive the browse-SPA instance links (intent, + // unit, feedback). Null for a local-only repo → those render unlinked. + // Resolved once per status-line render. The studio/stage DEFINITION links + // are repo-independent (static site routes), so they work even offline. + const repo = repoCoords() + const intentUrl = intentBrowseUrl(repo, slug) ?? undefined + const studioUrl = studioDefUrl(studio) ?? undefined + // ── intent-level SETUP phases (precede any stage walk) ── // These mirror the pre-cursor selection gates in run-tick.ts. The // pipeline can't render yet (stages aren't resolvable), so we show the @@ -557,7 +608,9 @@ export function resolveStatuslineState(): StatuslineState | null { // setup state carries its own bar index over that band. const setupState = (phaseLabel: string, idx: number): StatuslineState => ({ intent: slug, + intentUrl, studio, + studioUrl, stages: [], activeStage: "", phaseLabel, @@ -606,8 +659,14 @@ export function resolveStatuslineState(): StatuslineState | null { const awaitingMerge = isAwaitingMerge(slug, { localOnly: true }) return { intent: slug, + intentUrl, studio, - stages: stageList.map((name) => ({ name, status: "done" as const })), + studioUrl, + stages: stageList.map((name) => ({ + name, + status: "done" as const, + url: stageDefUrl(studio, name) ?? undefined, + })), activeStage: "", phaseLabel: awaitingMerge ? "pending seal" : "sealed", phaseKind: awaitingMerge ? "pending_seal" : "sealed", @@ -676,20 +735,26 @@ export function resolveStatuslineState(): StatuslineState | null { // review / reflection / intent-scope fix-loop). Otherwise: done = // isStageComplete up to the active stage, active = the action's stage, // pending = the rest. + const dotUrl = (name: string): string | undefined => + stageDefUrl(studio, name) ?? undefined let stages: StatuslineStageDot[] if (pastAllStages) { - stages = stageList.map((name) => ({ name, status: "done" as const })) + stages = stageList.map((name) => ({ + name, + status: "done" as const, + url: dotUrl(name), + })) } else { let sawActive = false stages = stageList.map((name) => { if (actStage && name === actStage) { sawActive = true - return { name, status: "active" as const } + return { name, status: "active" as const, url: dotUrl(name) } } if (!sawActive && isStageComplete(iDir, studio, name, mode)) { - return { name, status: "done" as const } + return { name, status: "done" as const, url: dotUrl(name) } } - return { name, status: "pending" as const } + return { name, status: "pending" as const, url: dotUrl(name) } }) } const activeStage = actStage @@ -824,7 +889,7 @@ export function resolveStatuslineState(): StatuslineState | null { // Show the WHOLE current wave — no concurrency slice. unitBars already // bounds to the active dependency level (the cursor's wave), so the // line is the wave itself, not an arbitrary MAX_CONCURRENT cap. - const bars = unitBars(studio, activeStage, iDir) + const bars = unitBars(studio, activeStage, iDir, repo, slug) if (bars.length > 0) itemBars = bars } else if (kind === "fixloop") { const bars: ItemBar[] = [] @@ -833,11 +898,20 @@ export function resolveStatuslineState(): StatuslineState | null { ...feedbackBars( join(iDir, "stages", activeStage, "feedback"), resolveStageFixHats(studio, activeStage), + repo, + slug, + activeStage, ), ) } bars.push( - ...feedbackBars(join(iDir, "feedback"), resolveStudioFixHats(studio)), + ...feedbackBars( + join(iDir, "feedback"), + resolveStudioFixHats(studio), + repo, + slug, + "", + ), ) // Highest-severity first across BOTH scopes — mirrors the fix-loop's // dispatch order (`feedbackSeverityRank`: blocker < high < medium < @@ -1008,7 +1082,9 @@ export function resolveStatuslineState(): StatuslineState | null { return { intent: slug, + intentUrl, studio, + studioUrl, stages, activeStage, phaseLabel: label, diff --git a/packages/haiku/src/tools/orchestrator/haiku_auth_login.ts b/packages/haiku/src/tools/orchestrator/haiku_auth_login.ts new file mode 100644 index 000000000..3a38dc43c --- /dev/null +++ b/packages/haiku/src/tools/orchestrator/haiku_auth_login.ts @@ -0,0 +1,351 @@ +// tools/orchestrator/haiku_auth_login.ts — broker-driven provider OAuth login. +// +// Phase 3 of provider OAuth. Runs the haikumethod.ai auth-broker handshake — +// a brokered authorization-code flow (NOT the provider's native RFC-8628 +// device flow): the broker does the OAuth code exchange server-side; the CLI +// just opens the verification URL and polls the session for the relayed token. +// Contract matches `deploy/auth-proxy/src/cli.ts`: +// 1. POST {AUTH_PROXY}/cli/start → { session_id, verification_url, expires_in } +// 2. open verification_url in the user's browser (best-effort) +// 3. POLL {AUTH_PROXY}/cli/poll { session_id } until status:"ready" — the +// token bundle is spread at the TOP LEVEL of the ready response. +// 4. writeProviderToken(provider, bundle) → ~/.haiku/settings.json +// +// `provider` is OPTIONAL — when omitted it's inferred from the repo's origin +// host (github.com → github, gitlab host → gitlab). The access/refresh token +// is NEVER echoed back; the response is { ok, provider, account } only. +// +// The network + browser are factored behind an injectable LoginDeps so the +// handshake is unit-testable (start → pending → ready) without real I/O. The +// default deps use global fetch, a cross-platform browser open, and real time; +// tests pass their own fetch/now/sleep/openUrl. + +import { spawn } from "node:child_process" +import { platform } from "node:os" +import { + parseGitRemote, + providerFromHost, + readOriginRemoteUrl, +} from "../../git-worktree.js" +import { readProviderToken, writeProviderToken } from "../../global-settings.js" +import type { + ProviderName, + ProviderToken, +} from "../../state/schemas/global-settings.js" +import { + HAIKU_AUTH_LOGIN_INPUT_SCHEMA, + type HaikuAuthLoginInput, + validateHaikuAuthLoginInputSchema, +} from "../../state/schemas/index.js" +import { + jsonSchemaOf, + validateToolInput, +} from "../../state/schemas/inputs/_validate.js" +import { defineTool } from "../define.js" +import { text } from "./_text.js" + +/** Default broker base URL; override with HAIKU_AUTH_PROXY_URL. */ +export const DEFAULT_AUTH_PROXY_URL = "https://auth.haikumethod.ai" + +/** Resolve the broker base URL (env override → default), trailing slash trimmed. */ +export function authProxyBaseUrl(): string { + const raw = process.env.HAIKU_AUTH_PROXY_URL?.trim() + const base = raw && raw.length > 0 ? raw : DEFAULT_AUTH_PROXY_URL + return base.replace(/\/+$/, "") +} + +/** Broker `/cli/start` response — the session ticket. Matches + * `deploy/auth-proxy/src/cli.ts` `start()`: a session id (NOT an OAuth + * device_code — the broker runs the standard authorization-code flow + * server-side; the CLI just polls by session) + the verification URL + a + * session expiry. */ +export interface BrokerStartResponse { + session_id: string + verification_url: string + /** Session expiry (seconds from now). The broker mints a 10-min session. */ + expires_in?: number +} + +/** Broker `/cli/poll` response. `status` drives the loop. On `ready` the token + * bundle is spread at TOP LEVEL (not nested under `token`) — matches the + * broker's `poll()` (`{ status, provider, host, account, ...token }`). The + * broker has no "denied" status; a declined/abandoned auth simply `expired`s. */ +export interface BrokerPollResponse { + status: "pending" | "ready" | "consumed" | "expired" + access_token?: string + refresh_token?: string + expires_at?: string + scopes?: string[] + host?: string + account?: string + provider?: ProviderName + /** Optional human reason for expired. */ + error?: string +} + +/** Injectable dependencies so the handshake runs without real network/browser/time. */ +export interface LoginDeps { + fetch: typeof fetch + /** Current epoch ms — injectable so timeout logic is testable. */ + now: () => number + /** Resolve after `ms` — injectable so polls don't really sleep in tests. */ + sleep: (ms: number) => Promise + /** Open a URL in the user's browser — best-effort, never throws. */ + openUrl: (url: string) => void +} + +/** Outcome of a successful handshake (token captured but NOT returned to caller). */ +export interface LoginResult { + provider: ProviderName + account: string | null +} + +const POLL_MIN_INTERVAL_MS = 1000 +const POLL_MAX_INTERVAL_MS = 10_000 +const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000 + +/** Best-effort cross-platform browser open. Detached + ignored stdio so it + * never blocks the tool; swallows every error (headless CI has no browser). */ +function defaultOpenUrl(url: string): void { + try { + const plat = platform() + const cmd = + plat === "darwin" ? "open" : plat === "win32" ? "cmd" : "xdg-open" + const args = plat === "win32" ? ["/c", "start", "", url] : [url] + const child = spawn(cmd, args, { stdio: "ignore", detached: true }) + child.on("error", () => { + /* no browser available — the verification_url is still in the response */ + }) + child.unref() + } catch { + /* never let an open failure break login */ + } +} + +/** Default deps wired to real fetch / time / browser. */ +export function defaultLoginDeps(): LoginDeps { + return { + fetch: (...a: Parameters) => fetch(...a), + now: () => Date.now(), + sleep: (ms) => new Promise((r) => setTimeout(r, ms)), + openUrl: defaultOpenUrl, + } +} + +/** Clamp a server-suggested interval (seconds) into our ms bounds. */ +function pollIntervalMs(intervalSeconds: number | undefined): number { + const ms = (intervalSeconds ?? 2) * 1000 + return Math.min(POLL_MAX_INTERVAL_MS, Math.max(POLL_MIN_INTERVAL_MS, ms)) +} + +/** Stable named errors the handshake can throw — caller maps to MCP error codes. */ +export class LoginError extends Error { + code: string + constructor(code: string, message: string) { + super(message) + this.code = code + } +} + +/** + * Run the full broker handshake for `provider`. Pure w.r.t. its deps — the test + * drives start → pending → ready by scripting `deps.fetch`. On success it + * persists the token via writeProviderToken and returns safe metadata only. + */ +export async function runBrokerLogin( + provider: ProviderName, + deps: LoginDeps, + opts: { timeoutMs?: number } = {}, +): Promise { + const base = authProxyBaseUrl() + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS + + // 1) start + const startRes = await deps.fetch(`${base}/cli/start`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider }), + }) + if (!startRes.ok) { + throw new LoginError( + "auth_login_broker_start_failed", + `broker /cli/start returned HTTP ${startRes.status}`, + ) + } + const start = (await startRes.json()) as BrokerStartResponse + if (!start?.session_id || !start?.verification_url) { + throw new LoginError( + "auth_login_broker_start_invalid", + "broker /cli/start response missing session_id or verification_url", + ) + } + + // 2) open the verification URL (best-effort) + deps.openUrl(start.verification_url) + + // 3) poll until ready / consumed / expired / timeout + const expiryCapMs = (start.expires_in ?? 0) * 1000 + const deadline = + deps.now() + + (expiryCapMs > 0 ? Math.min(timeoutMs, expiryCapMs) : timeoutMs) + const waitMs = pollIntervalMs(undefined) + + while (deps.now() < deadline) { + const pollRes = await deps.fetch(`${base}/cli/poll`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ session_id: start.session_id }), + }) + if (!pollRes.ok) { + throw new LoginError( + "auth_login_broker_poll_failed", + `broker /cli/poll returned HTTP ${pollRes.status}`, + ) + } + const poll = (await pollRes.json()) as BrokerPollResponse + if (poll.status === "ready") { + // The broker spreads the token bundle at top level. `host` falls back + // to the provider default (the broker normalizes it on its side). + if (!poll.access_token) { + throw new LoginError( + "auth_login_broker_token_invalid", + "broker reported ready but returned no access_token", + ) + } + const token: ProviderToken = { + access_token: poll.access_token, + host: + poll.host ?? (provider === "github" ? "github.com" : "gitlab.com"), + obtained_at: new Date(deps.now()).toISOString(), + ...(poll.refresh_token ? { refresh_token: poll.refresh_token } : {}), + ...(poll.expires_at ? { expires_at: poll.expires_at } : {}), + ...(poll.scopes ? { scopes: poll.scopes } : {}), + ...(poll.account ? { account: poll.account } : {}), + } + writeProviderToken(provider, token) + return { provider, account: token.account ?? null } + } + if (poll.status === "expired" || poll.status === "consumed") { + throw new LoginError( + "auth_login_expired", + poll.error ?? + (poll.status === "consumed" + ? "the authorization session was already consumed" + : "the authorization request expired"), + ) + } + // status === "pending" → wait and re-poll + await deps.sleep(waitMs) + } + + throw new LoginError( + "auth_login_timeout", + "timed out waiting for the broker authorization to complete", + ) +} + +/** Infer the provider from the repo's origin remote host, or null when there's + * no remote / the host isn't a recognized provider. */ +export function inferProviderFromOrigin(): ProviderName | null { + const origin = readOriginRemoteUrl() + if (!origin) return null + const parsed = parseGitRemote(origin) + if (!parsed) return null + return providerFromHost(parsed.host) +} + +/** Is a stored token still usable? Absent → no. Present with an `expires_at` in + * the past → no (re-auth). No `expires_at` (PAT-style / non-expiring) → yes. */ +function tokenUsable(token: ProviderToken | null, nowMs: number): boolean { + if (!token?.access_token) return false + if (typeof token.expires_at === "string" && token.expires_at.length > 0) { + const exp = Date.parse(token.expires_at) + if (Number.isFinite(exp) && exp <= nowMs) return false + } + return true +} + +/** + * Ensure a usable provider token exists, AUTHENTICATING WHEN NEEDED — the + * engine never asks the agent/user to "go call haiku_auth_login first." If a + * valid token is stored, return it; otherwise run the broker handshake inline + * (opens the browser, polls, persists) and return the fresh token. `provider` + * is the repo's provider (origin host); callers pass it via + * `inferProviderFromOrigin()`. + * + * Returns null when auth can't be obtained (broker unreachable, declined, + * timed out, or no provider) — the caller decides what to do: PR/MR ops fall + * back to the gh/glab CLI, proof upload surfaces the failure. Never throws; + * the interactive flow's errors are swallowed into the null so a best-effort + * caller isn't broken by an auth that didn't complete. + */ +export async function ensureProviderToken( + provider: ProviderName, + deps: LoginDeps = defaultLoginDeps(), + opts: { timeoutMs?: number } = {}, +): Promise { + const existing = readProviderToken(provider) + if (tokenUsable(existing, deps.now())) return existing + try { + await runBrokerLogin(provider, deps, opts) + } catch { + return null + } + const fresh = readProviderToken(provider) + return tokenUsable(fresh, deps.now()) ? fresh : null +} + +export default defineTool({ + name: "haiku_auth_login", + description: + "Authenticate a Git provider (github / gitlab) via the haikumethod.ai OAuth broker so the engine can drive PR/MR ops and proof upload over the provider REST API. Opens a verification URL in the browser, polls until you approve, and stores the token in ~/.haiku/settings.json. `provider` is optional — inferred from the repo's origin host when omitted. Never returns the token value.", + inputSchema: jsonSchemaOf(HAIKU_AUTH_LOGIN_INPUT_SCHEMA), + async handle(args) { + const inputErr = validateToolInput( + args, + validateHaikuAuthLoginInputSchema, + "haiku_auth_login", + ) + if (inputErr) return inputErr + const { provider: requested } = args as HaikuAuthLoginInput + + // The AJV enum gate already constrained `requested` to a PROVIDER_NAME (or + // undefined); the TypeBox Static widens the spread enum to `string`. + const provider = + (requested as ProviderName | undefined) ?? inferProviderFromOrigin() + if (!provider) { + return text( + JSON.stringify({ + ok: false, + error: "auth_login_provider_unresolved", + message: + 'could not infer the provider from the repo origin; pass provider: "github" or "gitlab"', + }), + ) + } + + try { + const result = await runBrokerLogin(provider, defaultLoginDeps()) + return text( + JSON.stringify({ + ok: true, + provider: result.provider, + account: result.account, + }), + ) + } catch (err) { + if (err instanceof LoginError) { + return text( + JSON.stringify({ ok: false, error: err.code, message: err.message }), + ) + } + return text( + JSON.stringify({ + ok: false, + error: "auth_login_failed", + message: err instanceof Error ? err.message : String(err), + }), + ) + } + }, +}) diff --git a/packages/haiku/src/tools/orchestrator/haiku_auth_logout.ts b/packages/haiku/src/tools/orchestrator/haiku_auth_logout.ts new file mode 100644 index 000000000..b84c972f5 --- /dev/null +++ b/packages/haiku/src/tools/orchestrator/haiku_auth_logout.ts @@ -0,0 +1,43 @@ +// tools/orchestrator/haiku_auth_logout.ts — disconnect one Git provider by +// clearing its token from the GLOBAL store (~/.haiku/settings.json). +// +// Phase 1 of provider OAuth; pairs with haiku_auth_status. Idempotent: clearing +// an already-absent provider returns ok with was_connected:false (not an error). + +import { clearProviderToken } from "../../global-settings.js" +import { + HAIKU_AUTH_LOGOUT_INPUT_SCHEMA, + type HaikuAuthLogoutInput, + validateHaikuAuthLogoutInputSchema, +} from "../../state/schemas/index.js" +import { + jsonSchemaOf, + validateToolInput, +} from "../../state/schemas/inputs/_validate.js" +import { defineTool } from "../define.js" +import { text } from "./_text.js" + +export default defineTool({ + name: "haiku_auth_logout", + description: + "Disconnect a Git provider (github | gitlab) by clearing its stored auth token from ~/.haiku/settings.json. Idempotent — clearing an unconnected provider returns ok with was_connected:false.", + inputSchema: jsonSchemaOf(HAIKU_AUTH_LOGOUT_INPUT_SCHEMA), + async handle(args) { + const inputErr = validateToolInput( + args, + validateHaikuAuthLogoutInputSchema, + "haiku_auth_logout", + ) + if (inputErr) return inputErr + const { provider } = args as HaikuAuthLogoutInput + + const wasConnected = clearProviderToken(provider as "github" | "gitlab") + return text( + JSON.stringify({ + ok: true, + provider, + was_connected: wasConnected, + }), + ) + }, +}) diff --git a/packages/haiku/src/tools/orchestrator/haiku_auth_status.ts b/packages/haiku/src/tools/orchestrator/haiku_auth_status.ts new file mode 100644 index 000000000..11630919d --- /dev/null +++ b/packages/haiku/src/tools/orchestrator/haiku_auth_status.ts @@ -0,0 +1,50 @@ +// tools/orchestrator/haiku_auth_status.ts — report which providers the engine +// is authenticated to (for the engine-driven MR/PR + proof-upload ops). +// +// Reads the GLOBAL token store (~/.haiku/settings.json) and returns each +// connected provider's SAFE metadata — account, scopes, host, expiry, and a +// derived `expired` flag — and NEVER the access/refresh token values. Optional +// `provider` narrows to one. Phase 1 of provider OAuth; pairs with +// haiku_auth_logout. The broker handshake (haiku_auth_login) is Phase 3. + +import { listConnectedProviders } from "../../global-settings.js" +import { + HAIKU_AUTH_STATUS_INPUT_SCHEMA, + type HaikuAuthStatusInput, + validateHaikuAuthStatusInputSchema, +} from "../../state/schemas/index.js" +import { + jsonSchemaOf, + validateToolInput, +} from "../../state/schemas/inputs/_validate.js" +import { defineTool } from "../define.js" +import { text } from "./_text.js" + +export default defineTool({ + name: "haiku_auth_status", + description: + "Show which Git providers (github / gitlab) the engine is authenticated to for MR/PR operations and proof upload. Returns each connected provider's account, scopes, host, expiry, and whether the token is expired — never the token value itself. Optional `provider` narrows to one.", + inputSchema: jsonSchemaOf(HAIKU_AUTH_STATUS_INPUT_SCHEMA), + async handle(args) { + const inputErr = validateToolInput( + args, + validateHaikuAuthStatusInputSchema, + "haiku_auth_status", + ) + if (inputErr) return inputErr + const { provider } = args as HaikuAuthStatusInput + + const all = listConnectedProviders() + const providers = provider + ? all.filter((p) => p.provider === provider) + : all + + return text( + JSON.stringify({ + ok: true, + connected: providers.length > 0, + providers, + }), + ) + }, +}) diff --git a/packages/haiku/src/tools/orchestrator/haiku_await_gate.ts b/packages/haiku/src/tools/orchestrator/haiku_await_gate.ts index 12af940c3..c00719406 100644 --- a/packages/haiku/src/tools/orchestrator/haiku_await_gate.ts +++ b/packages/haiku/src/tools/orchestrator/haiku_await_gate.ts @@ -451,7 +451,7 @@ export default defineTool({ ) } stampGateApproval(slug, "intent_completion", stage) - workflowIntentComplete(slug) + await workflowIntentComplete(slug) syncSessionMetadata(slug, stFile) const gateResult = { action: "intent_complete", @@ -520,7 +520,7 @@ export default defineTool({ } if (nextStage) { stampGateApproval(slug, "stage_gate", stage) - workflowAdvanceStage(slug, stage, nextStage) + await workflowAdvanceStage(slug, stage, nextStage) syncSessionMetadata(slug, stFile) const gateResult = { action: "advance_stage", @@ -591,7 +591,7 @@ export default defineTool({ // signal (gate.ts reconciles on branch-merged-into-intent-main). const stagePrUrl = existingStagePr.url const { markPullRequestReady } = await import("../../git-worktree.js") - const ready = markPullRequestReady(stagePrUrl) + const ready = await markPullRequestReady(stagePrUrl) try { const intentMd = join(intentDir(slug), "intent.md") setFrontmatterField(intentMd, "external_review_url", stagePrUrl) @@ -616,7 +616,7 @@ export default defineTool({ } } else if (isGitRepo()) { const { openStagePullRequest } = await import("../../git-worktree.js") - const opened = openStagePullRequest({ slug, stage }) + const opened = await openStagePullRequest({ slug, stage }) if (opened.createdUrl) { // Persist the URL on intent.md so the next tick // (and the discoverReviewUrl polling in diff --git a/packages/haiku/src/tools/orchestrator/haiku_drop_stage.ts b/packages/haiku/src/tools/orchestrator/haiku_drop_stage.ts index 85c2c3ce7..625b87c76 100644 --- a/packages/haiku/src/tools/orchestrator/haiku_drop_stage.ts +++ b/packages/haiku/src/tools/orchestrator/haiku_drop_stage.ts @@ -23,13 +23,11 @@ import { existsSync, readFileSync } from "node:fs" import { join } from "node:path" import { deleteStageBranch, ensureOnStageBranch } from "../../git-worktree.js" import { - resolveIntentStages, + findCurrentStageFromMain, + resolveCanonicalIntentStages, resolveStageOptional, } from "../../orchestrator/studio.js" -import { - findCurrentStage, - listUnitPaths, -} from "../../orchestrator/workflow/cursor.js" +import { listUnitPaths } from "../../orchestrator/workflow/cursor.js" import { HAIKU_DROP_STAGE_INPUT_SCHEMA, type HaikuDropStageInput, @@ -89,8 +87,14 @@ export default defineTool({ } // Guard 1 — the stage must be the intent's ACTIVE stage. Drop is an - // at-arrival decision; the cursor must currently be on it. - const activeStage = findCurrentStage(slug, studio, iDir) ?? "" + // at-arrival decision; the cursor must currently be on it. Resolve the + // active stage from the CANONICAL (intent-main) plan, NOT the current + // branch checkout — the deadlock that motivates this fix is exactly the + // two disagreeing: haiku_run_next reads main and keeps arriving at the + // dropped stage, while this guard read the diverged stage branch (where + // the old buggy drop already removed it) and refused as not-active. Both + // now read main, so they agree. + const activeStage = findCurrentStageFromMain(slug, studio) ?? "" if (stage !== activeStage) { return text( JSON.stringify({ @@ -129,7 +133,7 @@ export default defineTool({ // materializes the current plan (handles a legacy intent whose `stages` // wasn't materialized yet); filtering out `stage` both materializes AND // drops in one write. - const planStages = resolveIntentStages(intentFm, studio) + const planStages = resolveCanonicalIntentStages(slug, studio, intentFm) const droppedIdx = planStages.indexOf(stage) const nextStage = planStages[droppedIdx + 1] const nextStages = planStages.filter((s) => s !== stage) diff --git a/packages/haiku/src/tools/orchestrator/haiku_intent_create.ts b/packages/haiku/src/tools/orchestrator/haiku_intent_create.ts index 419d6d85b..56271c6d8 100644 --- a/packages/haiku/src/tools/orchestrator/haiku_intent_create.ts +++ b/packages/haiku/src/tools/orchestrator/haiku_intent_create.ts @@ -466,7 +466,9 @@ export default defineTool({ // The engine flips draft → ready in workflowIntentComplete on the // final approval. Best-effort: failures stamp draft_pr_status: // "failed" but never block intent creation. Skipped silently when - // the repo has no provider CLI (gh / glab) on PATH. + // the repo has no provider CLI (gh / glab) on PATH. (This one-time + // draft open runs in a synchronous handler, so it stays on the CLI; the + // token-backed REST path covers the async-reachable PR ops instead.) let draftPrMessage = "" if (isGitRepo() && detectPrTool() !== null) { try { diff --git a/packages/haiku/src/tools/orchestrator/haiku_run_next.ts b/packages/haiku/src/tools/orchestrator/haiku_run_next.ts index 88b390a17..22711c1d9 100644 --- a/packages/haiku/src/tools/orchestrator/haiku_run_next.ts +++ b/packages/haiku/src/tools/orchestrator/haiku_run_next.ts @@ -48,6 +48,9 @@ import { PR_INTERACTION_ROLES } from "../../orchestrator/review-role-classes.js" import { findCurrentStage, isStageComplete, + // closing-brief gate (#17) — fires in the complete_stage interception + // below for the autopilot / prior-stage-merge path (no user gate there). + stageOwesClosingBrief, stageOwesObservations, } from "../../orchestrator/workflow/cursor.js" import { runWorkflowTick } from "../../orchestrator/workflow/run-tick.js" @@ -58,6 +61,7 @@ import { enrichActionWithPreview, getPrepareGateReview, type OrchestratorAction, + resolveIntentStages, } from "../../orchestrator.js" /** Single-source dispatch: one workflow tick → one action. Handles @@ -697,6 +701,44 @@ export default defineTool({ } } } else { + // PRE-TICK DANGLING-BRANCH ESCAPE (#22). If the checkout is + // parked on a stage branch whose stage is no longer in the + // canonical plan — a dropped optional stage whose branch + // wasn't reaped, or a stale checkout left after a heal — the + // user is stranded ("dropped the stage, was still on the + // branch, couldn't rescue off of it"). The post-walk + // realignment below is gated on the cursor's action carrying + // a `stage`, so a stage-less intent-level action (or any + // confusion) leaves us stuck on the dead branch forever. + // Escape to the active stage (or intent main) BEFORE the + // cursor walks, so every tick guarantees we're on a PLANNED + // branch. Conservative: we switch OFF, we do NOT reap — any + // stranded commits on the dangling branch survive for the + // user to recover. + { + const here = getCurrentBranch().startsWith(`haiku/${slug}/`) + ? getCurrentBranch().slice(`haiku/${slug}/`.length) + : "" + if (here && here !== "main") { + const plan = resolveIntentStages(im, studio) + if (!plan.includes(here)) { + const active = findCurrentStage(slug, studio) + const escapeGuard = ensureOnStageBranch( + slug, + active ?? undefined, + ) + if (!escapeGuard.ok) { + return buildGuardResponse( + slug, + active ?? undefined, + escapeGuard, + "run_next entry — escape dropped/dangling stage branch", + ) + } + } + } + } + // PRE-CURSOR DOWNSTREAM SYNC. The cursor's walk reads // per-unit FM from the current working tree. If the // branch isn't up to date with intent main (and intent @@ -1408,6 +1450,29 @@ export default defineTool({ } completeStageLastSig = sig const stageToComplete = result.stage + // Forward-only CLOSING-BRIEF gate (#17, 2026-05-28). Before a + // stage merges, rewrite the SAME user-facing BRIEF.md the + // pre-execute brief authored — flipping it from "this is what I + // am going to do" to "this is what I did" (the post-execution + // summary the human sees once work has landed). Fires here, in the + // complete_stage interception path, NOT in the cursor walk: when a + // stage's units are all signed, findCurrentStage advances to the + // NEXT stage, so a cursor-walk gate on the just-finished stage is + // never reached. This path runs for the frontier stage the cursor + // just produced complete_stage for — same forward-only guarantee + // the observations gate below relies on. Gated on the brief's OWN + // `phase: post` frontmatter (BRIEF.md already exists from the pre + // firing). Ordered BEFORE observations: the public "what I did" + // brief precedes the private reflection note; both precede merge. + if (stageOwesClosingBrief(intentDir(slug), stageToComplete)) { + result = { + action: "write_brief", + intent: slug, + stage: stageToComplete, + phase: "post", + } + break + } // Forward-only observations gate. A stage owes its free-form // observations.md before it merges (reflection on). Instead of // merging, hand the agent the record_observations instruction; @@ -1697,7 +1762,7 @@ export default defineTool({ ) { try { const { openStagePullRequest } = await import("../../git-worktree.js") - const opened = openStagePullRequest({ + const opened = await openStagePullRequest({ slug, stage: result.stage as string, }) @@ -1814,9 +1879,6 @@ export default defineTool({ : ((result.next_stage as string | null) ?? null) if (isUserGate && gateKind === "approval" && stage) { try { - const { resolveIntentStages } = await import( - "../../orchestrator/studio.js" - ) const intentFile = join(findHaikuRoot(), "intents", slug, "intent.md") const intentFm = existsSync(intentFile) ? parseFrontmatter(readFileSync(intentFile, "utf8")).data diff --git a/packages/haiku/src/tools/orchestrator/haiku_upload_proof.ts b/packages/haiku/src/tools/orchestrator/haiku_upload_proof.ts new file mode 100644 index 000000000..1e021a502 --- /dev/null +++ b/packages/haiku/src/tools/orchestrator/haiku_upload_proof.ts @@ -0,0 +1,336 @@ +// tools/orchestrator/haiku_upload_proof.ts — upload a runtime-verification +// proof file to the intent's / stage's change request over the provider REST +// API (no `gh` / `glab` shell-out). +// +// GitHub → upload the file as a release asset (uploads.github.com), so the +// proof is durably attached to the delivery and linkable from the PR. +// GitLab → POST the file to the project uploads API +// (/api/v4/projects/:id/uploads), which returns a markdown ref the +// caller can drop into the MR description / a note. +// +// Provider is detected from the origin host. The bearer comes from the GLOBAL +// token store, AUTHENTICATING WHEN NEEDED via `ensureProviderToken` (the broker +// handshake runs inline if no token is stored — the engine never tells the +// agent to authenticate first). Only a genuinely unobtainable auth (broker +// unreachable / declined / timed out) surfaces `proof_upload_auth_unavailable`. +// +// The provider API call is factored behind an injectable fetch so the test can +// assert the right endpoint + headers per provider without network. + +import { existsSync, readFileSync, statSync } from "node:fs" +import { basename } from "node:path" +import { + parseGitRemote, + providerFromHost, + readOriginRemoteUrl, +} from "../../git-worktree.js" +import type { ProviderName } from "../../state/schemas/global-settings.js" +import { + HAIKU_UPLOAD_PROOF_INPUT_SCHEMA, + type HaikuUploadProofInput, + validateHaikuUploadProofInputSchema, +} from "../../state/schemas/index.js" +import { + jsonSchemaOf, + validateToolInput, +} from "../../state/schemas/inputs/_validate.js" +import { defineTool } from "../define.js" +import { text } from "./_text.js" +import { ensureProviderToken } from "./haiku_auth_login.js" + +/** What the caller hands the uploader: the parsed remote, the proof bytes, the + * bearer, and the (optional) PR/MR URL the proof should reference. */ +export interface ProofUploadContext { + provider: ProviderName + host: string + owner: string + repo: string + token: string + fileName: string + /** Proof bytes. `Buffer` (what `readFileSync` returns) is a valid `BodyInit` + * and `BlobPart`; tests pass a `Buffer.from([...])`. */ + fileBytes: Buffer + prUrl?: string +} + +/** Result of an upload — the durable asset/upload URL (or markdown ref). */ +export interface ProofUploadResult { + provider: ProviderName + /** Canonical URL or markdown reference for the uploaded proof. */ + url: string + /** GitLab returns a relative markdown snippet too; null for GitHub. */ + markdown: string | null +} + +/** Stable named errors the uploader can throw — mapped to MCP error codes. */ +export class ProofUploadError extends Error { + code: string + constructor(code: string, message: string) { + super(message) + this.code = code + } +} + +/** GitHub API base for a host (github.com → api.github.com; Enterprise → + * https:///api/v3). Uploads use uploads.github.com on .com. */ +function githubApiBase(host: string): { api: string; uploads: string } { + if (host === "github.com") { + return { + api: "https://api.github.com", + uploads: "https://uploads.github.com", + } + } + // GitHub Enterprise + return { + api: `https://${host}/api/v3`, + uploads: `https://${host}/api/uploads`, + } +} + +/** GitLab API base for a host (always /api/v4 on the host). */ +function gitlabApiBase(host: string): string { + return `https://${host}/api/v4` +} + +/** + * Upload to GitHub as a release asset. Ensures (or creates) a `haiku-proof` + * release, then PUTs the asset. Returns the asset's browser_download_url. + * Factored over an injectable fetch for testability. + */ +export async function uploadProofGitHub( + ctx: ProofUploadContext, + fetchImpl: typeof fetch, +): Promise { + const { api, uploads } = githubApiBase(ctx.host) + const repoPath = `${ctx.owner}/${ctx.repo}` + const tag = "haiku-proof" + const authHeaders = { + authorization: `Bearer ${ctx.token}`, + accept: "application/vnd.github+json", + "x-github-api-version": "2022-11-28", + } + + // 1) ensure a release exists for the proof tag (idempotent: GET then create) + let releaseId: number | null = null + const getRel = await fetchImpl( + `${api}/repos/${repoPath}/releases/tags/${tag}`, + { headers: authHeaders }, + ) + if (getRel.ok) { + const rel = (await getRel.json()) as { id: number } + releaseId = rel.id + } else if (getRel.status === 404) { + const createRel = await fetchImpl(`${api}/repos/${repoPath}/releases`, { + method: "POST", + headers: { ...authHeaders, "content-type": "application/json" }, + body: JSON.stringify({ + tag_name: tag, + name: "H·AI·K·U runtime-verification proofs", + body: "Runtime-verification proof artifacts uploaded by the H·AI·K·U engine.", + }), + }) + if (!createRel.ok) { + throw new ProofUploadError( + "proof_upload_github_release_failed", + `creating proof release returned HTTP ${createRel.status}`, + ) + } + const rel = (await createRel.json()) as { id: number } + releaseId = rel.id + } else { + throw new ProofUploadError( + "proof_upload_github_release_failed", + `looking up proof release returned HTTP ${getRel.status}`, + ) + } + + // 2) upload the asset + const assetName = encodeURIComponent(ctx.fileName) + const uploadUrl = `${uploads}/repos/${repoPath}/releases/${releaseId}/assets?name=${assetName}` + const uploadRes = await fetchImpl(uploadUrl, { + method: "POST", + headers: { + ...authHeaders, + "content-type": "application/octet-stream", + }, + // Buffer is a valid request body at runtime; the DOM `BodyInit` type is + // narrower than the Node reality, so cast through it. + body: ctx.fileBytes as unknown as BodyInit, + }) + if (!uploadRes.ok) { + throw new ProofUploadError( + "proof_upload_github_asset_failed", + `uploading proof asset returned HTTP ${uploadRes.status}`, + ) + } + const asset = (await uploadRes.json()) as { browser_download_url: string } + return { + provider: "github", + url: asset.browser_download_url, + markdown: null, + } +} + +/** + * Upload to GitLab via the project uploads API. Returns the file's full URL + + * the markdown snippet GitLab hands back (drop it into the MR). Factored over an + * injectable fetch for testability. + */ +export async function uploadProofGitLab( + ctx: ProofUploadContext, + fetchImpl: typeof fetch, +): Promise { + const api = gitlabApiBase(ctx.host) + const projectId = encodeURIComponent(`${ctx.owner}/${ctx.repo}`) + const form = new FormData() + form.append( + "file", + // Buffer is a valid BlobPart at runtime; cast through the narrower DOM type. + new Blob([ctx.fileBytes as unknown as BlobPart], { + type: "application/octet-stream", + }), + ctx.fileName, + ) + const res = await fetchImpl(`${api}/projects/${projectId}/uploads`, { + method: "POST", + // OAuth access tokens (what the broker relays) require Authorization: + // Bearer — PRIVATE-TOKEN is PAT-only and 401s an OAuth token. Matches + // provider-rest.ts's gitlabAuthHeaders. + headers: { authorization: `Bearer ${ctx.token}` }, + body: form, + }) + if (!res.ok) { + throw new ProofUploadError( + "proof_upload_gitlab_failed", + `GitLab uploads API returned HTTP ${res.status}`, + ) + } + const out = (await res.json()) as { + url?: string + full_path?: string + markdown?: string + } + const rel = out.full_path ?? out.url ?? "" + const full = rel.startsWith("http") + ? rel + : `https://${ctx.host}/${ctx.owner}/${ctx.repo}${rel}` + return { + provider: "gitlab", + url: full, + markdown: out.markdown ?? null, + } +} + +/** Route to the right provider uploader. */ +export async function uploadProof( + ctx: ProofUploadContext, + fetchImpl: typeof fetch, +): Promise { + if (ctx.provider === "github") return uploadProofGitHub(ctx, fetchImpl) + return uploadProofGitLab(ctx, fetchImpl) +} + +export default defineTool({ + name: "haiku_upload_proof", + description: + "Upload a runtime-verification proof file to the intent's / stage's change request over the provider REST API. GitHub → release asset; GitLab → project uploads API + MR-ready markdown ref. Provider is detected from the repo origin; the bearer comes from ~/.haiku/settings.json (run haiku_auth_login first). Returns the durable proof URL.", + inputSchema: jsonSchemaOf(HAIKU_UPLOAD_PROOF_INPUT_SCHEMA), + async handle(args) { + const inputErr = validateToolInput( + args, + validateHaikuUploadProofInputSchema, + "haiku_upload_proof", + ) + if (inputErr) return inputErr + const { path, pr_url } = args as HaikuUploadProofInput + + // 1) proof file must exist + be a regular file + if (!existsSync(path)) { + return text( + JSON.stringify({ + ok: false, + error: "proof_upload_path_missing", + message: `proof path not found: ${path}`, + }), + ) + } + if (!statSync(path).isFile()) { + return text( + JSON.stringify({ + ok: false, + error: "proof_upload_path_not_file", + message: `proof path is not a regular file: ${path}`, + }), + ) + } + + // 2) detect provider from origin + const origin = readOriginRemoteUrl() + const parsed = origin ? parseGitRemote(origin) : null + const provider = parsed ? providerFromHost(parsed.host) : null + if (!parsed || !provider) { + return text( + JSON.stringify({ + ok: false, + error: "proof_upload_provider_unresolved", + message: + "could not detect a supported provider from the repo origin remote", + }), + ) + } + + // 3) bearer — AUTH WHEN NEEDED. The engine never tells the agent to + // "go authenticate first": if there's no usable token, ensureProviderToken + // runs the broker handshake inline (browser + poll) for the repo's + // provider, then returns the fresh token. Only a genuinely unobtainable + // auth (broker unreachable / declined / timed out) surfaces an error. + const token = await ensureProviderToken(provider) + if (!token?.access_token) { + return text( + JSON.stringify({ + ok: false, + error: "proof_upload_auth_unavailable", + message: `could not authenticate to ${provider} via the haikumethod.ai broker — the auth flow didn't complete (declined, timed out, or broker unreachable). Proof was not uploaded.`, + }), + ) + } + + // 4) upload + try { + const result = await uploadProof( + { + provider, + host: parsed.host, + owner: parsed.owner, + repo: parsed.repo, + token: token.access_token, + fileName: basename(path), + fileBytes: readFileSync(path), + prUrl: pr_url, + }, + (...a: Parameters) => fetch(...a), + ) + return text( + JSON.stringify({ + ok: true, + provider: result.provider, + url: result.url, + markdown: result.markdown, + }), + ) + } catch (err) { + if (err instanceof ProofUploadError) { + return text( + JSON.stringify({ ok: false, error: err.code, message: err.message }), + ) + } + return text( + JSON.stringify({ + ok: false, + error: "proof_upload_failed", + message: err instanceof Error ? err.message : String(err), + }), + ) + } + }, +}) diff --git a/packages/haiku/src/tools/orchestrator/haiku_write_brief.ts b/packages/haiku/src/tools/orchestrator/haiku_write_brief.ts new file mode 100644 index 000000000..6c92e029f --- /dev/null +++ b/packages/haiku/src/tools/orchestrator/haiku_write_brief.ts @@ -0,0 +1,167 @@ +// tools/orchestrator/haiku_write_brief.ts — write the current stage's +// user-facing BRIEF.md. +// +// The briefer subagent calls this during the `write_brief` cursor action and +// supplies ONLY the prose body. EVERYTHING else is engine-owned — this tool is +// only ever called in-flow, so the engine already knows where it is: +// - intent: resolved from the current branch (`haiku//…`), or the sole +// active intent in filesystem mode. +// - stage: the branch's stage segment when on a stage branch, else the +// cursor's current stage (`findCurrentStage`). +// - phase: `pre` when no BRIEF.md exists yet ("what I'm going to do"), `post` +// when rewriting the existing one ("what I did" — the closing brief). This +// mirrors the two-gate model: `stageOwesBrief` fires only when BRIEF.md is +// ABSENT, `stageOwesClosingBrief` only when it EXISTS with `phase != post`. +// +// Frontmatter is written via gray-matter (`matter.stringify`) — never a +// hand-rolled `---` block. + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import matter from "gray-matter" +import { ensureOnStageBranch } from "../../git-worktree.js" +import { findCurrentStage } from "../../orchestrator/workflow/cursor.js" +import { + HAIKU_WRITE_BRIEF_INPUT_SCHEMA, + validateHaikuWriteBriefInputSchema, +} from "../../state/schemas/index.js" +import { + jsonSchemaOf, + validateToolInput, +} from "../../state/schemas/inputs/_validate.js" +import { + findHaikuRoot, + gitCommitState, + intentFromCurrentBranch, + isGitRepo, + listVisibleIntents, + parseFrontmatter, +} from "../../state-tools.js" +import { defineTool } from "../define.js" +import { text } from "./_text.js" + +function err(code: string, message: string) { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { error: code, tool: "haiku_write_brief", message }, + null, + 2, + ), + }, + ], + isError: true, + } +} + +export default defineTool({ + name: "haiku_write_brief", + description: + "Write the current stage's user-facing BRIEF.md. Supply ONLY the markdown body (no frontmatter, no intent, no stage). The engine resolves the intent + stage from the current cursor position and stamps the `phase:` frontmatter itself — `pre` for the first write (the plan) and `post` when rewriting the existing brief at stage finish (what shipped). Called in-flow during the `write_brief` cursor action; the action's `phase` tells you which prose to write, but you never specify it.", + inputSchema: jsonSchemaOf(HAIKU_WRITE_BRIEF_INPUT_SCHEMA), + handle(args) { + const validation = validateToolInput( + args as Record, + validateHaikuWriteBriefInputSchema, + "haiku_write_brief", + ) + if (validation) return validation + + const body = args.body as string + + // Resolve the intent from the current cursor position — the branch in a + // git repo, the sole active intent in filesystem mode. The engine never + // takes intent/stage from the agent here. + const branchInfo = intentFromCurrentBranch() + let slug = branchInfo?.slug ?? "" + if (!slug && !isGitRepo()) { + const intentsDir = join(findHaikuRoot(), "intents") + const active = existsSync(intentsDir) + ? listVisibleIntents(intentsDir).filter( + (i) => (i.data.status as string) !== "completed", + ) + : [] + if (active.length === 1) slug = active[0].slug + } + if (!slug) { + return err( + "write_brief_no_active_intent", + "Could not resolve the active intent. haiku_write_brief is only called in-flow — switch to the intent branch (`haiku//main` or `haiku//`) and let the engine drive the write_brief action.", + ) + } + + const root = findHaikuRoot() + const intentDir = join(root, "intents", slug) + const intentMd = join(intentDir, "intent.md") + if (!existsSync(intentMd)) { + return err("intent_not_found", `Intent '${slug}' not found.`) + } + + // Resolve the stage: the branch's stage segment when on a stage branch, + // else the cursor's current stage. + const intentFm = parseFrontmatter(readFileSync(intentMd, "utf8")).data ?? {} + const studio = (intentFm.studio as string) || "" + const stage = branchInfo?.stage ?? findCurrentStage(slug, studio, intentDir) + if (!stage) { + return err( + "write_brief_no_active_stage", + `Could not resolve the active stage for intent '${slug}'. The cursor drives the brief's stage — this tool is only called in-flow during a write_brief action.`, + ) + } + + const stageDir = join(intentDir, "stages", stage) + const briefPath = join(stageDir, "BRIEF.md") + + // Engine-owned phase: absent → pre (the plan), present → post (the + // closing rewrite). Same signal stageOwesClosingBrief gates on, so the + // file's frontmatter can never disagree with the cursor. + const phase: "pre" | "post" = existsSync(briefPath) ? "post" : "pre" + + // Stage-scoped artifact → lands on the stage branch, like every other + // engine-managed per-stage file. + const branchGuard = ensureOnStageBranch(slug, stage) + if (!branchGuard.ok) { + return { + content: [ + { + type: "text" as const, + text: `Error: branch enforcement failed for brief on '${slug}/${stage}' — ${branchGuard.message}. Resolve manually and retry.`, + }, + ], + isError: true, + } + } + + mkdirSync(stageDir, { recursive: true }) + + // Preserve any frontmatter the prior (pre) brief carried, then set the + // engine-owned phase. gray-matter caches parse results by content and + // returns a SHARED object, so build a fresh data object via spread + // rather than mutating parsed.data in place. + let priorData: Record = {} + if (existsSync(briefPath)) { + priorData = { ...(matter(readFileSync(briefPath, "utf8")).data ?? {}) } + } + const fm = { ...priorData, phase } + writeFileSync(briefPath, matter.stringify(body, fm)) + + gitCommitState(`haiku: write ${phase} brief for ${slug}/${stage}`) + + return text( + JSON.stringify( + { + action: "brief_written", + slug, + stage, + phase, + path: briefPath, + message: `Wrote the ${phase}-execute brief for '${slug}/${stage}'.`, + }, + null, + 2, + ), + ) + }, +}) diff --git a/packages/haiku/src/tools/orchestrator/index.ts b/packages/haiku/src/tools/orchestrator/index.ts index 7525a0bfd..81b306e78 100644 --- a/packages/haiku/src/tools/orchestrator/index.ts +++ b/packages/haiku/src/tools/orchestrator/index.ts @@ -10,6 +10,9 @@ // tools. As more tools migrate, the chain shrinks toward zero. import type { ToolDef } from "../types.js" +import haiku_auth_login from "./haiku_auth_login.js" +import haiku_auth_logout from "./haiku_auth_logout.js" +import haiku_auth_status from "./haiku_auth_status.js" import haiku_await_gate from "./haiku_await_gate.js" // v9: haiku_baseline_init removed — premise-witness model has no baseline.json. // v4: haiku_classify_drift removed — drift sweep auto-files FBs. @@ -35,10 +38,15 @@ import haiku_stage_elaboration_record from "./haiku_stage_elaboration_record.js" import haiku_stage_elaboration_seal from "./haiku_stage_elaboration_seal.js" import haiku_stage_reset from "./haiku_stage_reset.js" import haiku_unit_reset from "./haiku_unit_reset.js" +import haiku_upload_proof from "./haiku_upload_proof.js" +import haiku_write_brief from "./haiku_write_brief.js" export const orchestratorToolHandlers: ReadonlyMap = new Map( ( [ + haiku_auth_login, + haiku_auth_logout, + haiku_auth_status, haiku_await_gate, haiku_coverage_acknowledge, haiku_debug, @@ -62,6 +70,8 @@ export const orchestratorToolHandlers: ReadonlyMap = new Map( haiku_stage_elaboration_seal, haiku_stage_reset, haiku_unit_reset, + haiku_upload_proof, + haiku_write_brief, ] satisfies ToolDef[] ).map((t) => [t.name, t]), ) diff --git a/packages/haiku/test/_v4-fixtures.mjs b/packages/haiku/test/_v4-fixtures.mjs index c6d2c99ce..58c5b38e8 100644 --- a/packages/haiku/test/_v4-fixtures.mjs +++ b/packages/haiku/test/_v4-fixtures.mjs @@ -685,30 +685,18 @@ export async function runTickWithBranchAlignment( if (pendingMergeStage) return pendingMergeStage const action = dispatchOrchestratorAction(slug, "") if (autoBrief && action?.action === "write_brief" && action.stage) { - // Briefer stand-in: write BRIEF.md on the (already-aligned) stage - // branch, then re-tick. Writing the brief doesn't move the active - // stage, so alignment stays put and the next walk sees the file. - const briefPath = join( - repoRoot, - ".haiku", - "intents", - slug, - "stages", - action.stage, - "BRIEF.md", + // Briefer stand-in: call the real haiku_write_brief tool with body + // only. The engine stamps the `phase:` frontmatter (pre/post) itself + // and commits on the aligned stage branch — so we exercise the same + // path production does, and the closing-brief gate clears. Writing + // the brief doesn't move the active stage, so alignment stays put + // and the next walk sees the file. + const { default: writeBrief } = await import( + "../src/tools/orchestrator/haiku_write_brief.ts" ) - writeFileSync(briefPath, "# Brief (test fixture)\n") - // Commit on the (already-aligned) stage branch — mirrors the - // brief being committed with the stage in production. - try { - execFileSync("git", ["add", "-A"], { cwd: repoRoot, stdio: "ignore" }) - execFileSync("git", ["commit", "-q", "-m", "test: stage brief"], { - cwd: repoRoot, - stdio: "ignore", - }) - } catch { - /* filesystem-mode or nothing to commit — non-fatal */ - } + // Body only — the tool resolves intent + stage from the aligned + // branch / cursor, exactly as it does in production. + await writeBrief.handle({ body: "# Brief (test fixture)\n" }) // Re-dispatch in place: we're already on the aligned branch with // BRIEF.md on disk, so the cursor advances past the brief without // re-running the branch reconciliation dance. diff --git a/packages/haiku/test/auth-login.test.mjs b/packages/haiku/test/auth-login.test.mjs new file mode 100644 index 000000000..8cb457c4d --- /dev/null +++ b/packages/haiku/test/auth-login.test.mjs @@ -0,0 +1,260 @@ +// auth-login.test.mjs — drive the broker handshake (start → pending → ready) +// through the injectable LoginDeps so it never touches network/browser/time, +// and assert the captured token is persisted to the GLOBAL store (temp dir). +// +// Imports the TS source via tsx (the test runner is `npx tsx`), matching +// global-settings.test.mjs. HAIKU_GLOBAL_DIR points at a temp dir per test so +// the real ~/.haiku/settings.json is never touched. + +import assert from "node:assert/strict" +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { test } from "node:test" + +const SRC = new URL("../src/", import.meta.url).pathname +const ORIG_DIR = process.env.HAIKU_GLOBAL_DIR +const ORIG_PROXY = process.env.HAIKU_AUTH_PROXY_URL + +function restoreProxy() { + if (ORIG_PROXY === undefined) delete process.env.HAIKU_AUTH_PROXY_URL + else process.env.HAIKU_AUTH_PROXY_URL = ORIG_PROXY +} + +function withTempGlobal(fn) { + const dir = mkdtempSync(join(tmpdir(), "haiku-login-")) + process.env.HAIKU_GLOBAL_DIR = dir + return Promise.resolve(fn(dir)).finally(() => { + if (ORIG_DIR === undefined) delete process.env.HAIKU_GLOBAL_DIR + else process.env.HAIKU_GLOBAL_DIR = ORIG_DIR + rmSync(dir, { recursive: true, force: true }) + }) +} + +/** Build a fake fetch from a queue of response factories; records calls. */ +function scriptedFetch(handlers) { + const calls = [] + const fetchImpl = async (url, init) => { + calls.push({ url: String(url), init: init ?? {} }) + const handler = handlers.shift() + if (!handler) throw new Error(`unexpected fetch to ${url}`) + return handler(String(url), init) + } + return { fetchImpl, calls } +} + +function jsonResponse(status, body) { + return { ok: status >= 200 && status < 300, status, json: async () => body } +} + +function fakeDeps(fetchImpl) { + return { + fetch: fetchImpl, + now: () => 0, // frozen clock — deadline math stays inside the timeout + sleep: async () => {}, // no real waiting between polls + openUrl: () => {}, // never open a browser in a test + } +} + +test("authProxyBaseUrl defaults and honors the env override", async () => { + const mod = await import(`${SRC}tools/orchestrator/haiku_auth_login.ts`) + delete process.env.HAIKU_AUTH_PROXY_URL + assert.equal(mod.authProxyBaseUrl(), mod.DEFAULT_AUTH_PROXY_URL) + process.env.HAIKU_AUTH_PROXY_URL = "https://broker.example.com/" + assert.equal(mod.authProxyBaseUrl(), "https://broker.example.com") // trailing slash trimmed + restoreProxy() +}) + +test("runBrokerLogin: start → pending → ready persists the token", async () => { + await withTempGlobal(async () => { + process.env.HAIKU_AUTH_PROXY_URL = "https://broker.test" + const mod = await import( + `${SRC}tools/orchestrator/haiku_auth_login.ts?d=ready` + ) + const gs = await import(`${SRC}global-settings.ts?d=login-ready`) + + const { fetchImpl, calls } = scriptedFetch([ + // /cli/start → broker mints a SESSION (not an OAuth device_code) + () => + jsonResponse(200, { + session_id: "SESS-123", + verification_url: "https://broker.test/verify/abc", + expires_in: 600, + }), + // /cli/poll #1 → pending + () => jsonResponse(200, { status: "pending" }), + // /cli/poll #2 → ready; the token bundle is SPREAD AT TOP LEVEL + () => + jsonResponse(200, { + status: "ready", + provider: "github", + host: "github.com", + account: "octocat", + access_token: "gho_secret_value", + scopes: ["repo"], + }), + ]) + + const result = await mod.runBrokerLogin("github", fakeDeps(fetchImpl)) + assert.equal(result.provider, "github") + assert.equal(result.account, "octocat") + + // start + two polls were all issued at the broker base; poll is keyed on + // session_id (NOT a device_code). + assert.equal(calls.length, 3) + assert.equal(calls[0].url, "https://broker.test/cli/start") + assert.equal(calls[1].url, "https://broker.test/cli/poll") + assert.equal(calls[2].url, "https://broker.test/cli/poll") + assert.equal(JSON.parse(calls[1].init.body).session_id, "SESS-123") + + // the token landed in the global store and is readable + const stored = gs.readProviderToken("github") + assert.ok(stored) + assert.equal(stored.access_token, "gho_secret_value") + assert.equal(stored.account, "octocat") + + // the result NEVER carries the token value + assert.equal("access_token" in result, false) + restoreProxy() + }) +}) + +test("runBrokerLogin: expired → LoginError(auth_login_expired), no token stored", async () => { + await withTempGlobal(async () => { + process.env.HAIKU_AUTH_PROXY_URL = "https://broker.test" + const mod = await import( + `${SRC}tools/orchestrator/haiku_auth_login.ts?d=expired` + ) + const gs = await import(`${SRC}global-settings.ts?d=login-expired`) + + // The broker has no "denied" — a declined/abandoned authorization just + // expires (session TTL). poll keys on session_id. + const { fetchImpl } = scriptedFetch([ + () => + jsonResponse(200, { + session_id: "SESS-9", + verification_url: "https://broker.test/verify/x", + }), + () => jsonResponse(200, { status: "expired", error: "session expired" }), + ]) + + await assert.rejects( + () => mod.runBrokerLogin("gitlab", fakeDeps(fetchImpl)), + (err) => { + assert.ok(err instanceof mod.LoginError) + assert.equal(err.code, "auth_login_expired") + return true + }, + ) + assert.equal(gs.readProviderToken("gitlab"), null) + restoreProxy() + }) +}) + +test("runBrokerLogin: broker /cli/start HTTP error → LoginError", async () => { + await withTempGlobal(async () => { + process.env.HAIKU_AUTH_PROXY_URL = "https://broker.test" + const mod = await import( + `${SRC}tools/orchestrator/haiku_auth_login.ts?d=starterr` + ) + const { fetchImpl } = scriptedFetch([() => jsonResponse(500, {})]) + await assert.rejects( + () => mod.runBrokerLogin("github", fakeDeps(fetchImpl)), + (err) => { + assert.ok(err instanceof mod.LoginError) + assert.equal(err.code, "auth_login_broker_start_failed") + return true + }, + ) + restoreProxy() + }) +}) + +test("haiku_auth_login rejects unknown provider via input gate", async () => { + const tool = (await import(`${SRC}tools/orchestrator/haiku_auth_login.ts`)) + .default + const res = await tool.handle({ provider: "bitbucket" }) + assert.equal(res.isError, true) + assert.match(res.content[0].text, /haiku_auth_login_input_invalid/) +}) + +test("haiku_auth_login rejects additional properties via input gate", async () => { + const tool = (await import(`${SRC}tools/orchestrator/haiku_auth_login.ts`)) + .default + const res = await tool.handle({ provider: "github", extra: true }) + assert.equal(res.isError, true) + assert.match(res.content[0].text, /haiku_auth_login_input_invalid/) +}) + +// ── ensureProviderToken: AUTH WHEN NEEDED (no "go call the auth tool first") ── + +test("ensureProviderToken: a usable stored token is returned WITHOUT calling the broker", async () => { + await withTempGlobal(async () => { + const mod = await import( + `${SRC}tools/orchestrator/haiku_auth_login.ts?d=ept-have` + ) + const gs = await import(`${SRC}global-settings.ts?d=ept-have`) + gs.writeProviderToken("github", { + access_token: "gho_existing", + host: "github.com", + account: "octocat", + obtained_at: "2026-05-28T00:00:00.000Z", + }) + // fetch THROWS if touched — proves no broker round-trip when a token exists. + const deps = fakeDeps(async () => { + throw new Error("broker must not be called when a token is present") + }) + const tok = await mod.ensureProviderToken("github", deps) + assert.ok(tok) + assert.equal(tok.access_token, "gho_existing") + }) +}) + +test("ensureProviderToken: no token → runs the broker handshake inline, returns the fresh token", async () => { + await withTempGlobal(async () => { + process.env.HAIKU_AUTH_PROXY_URL = "https://broker.test" + const mod = await import( + `${SRC}tools/orchestrator/haiku_auth_login.ts?d=ept-auth` + ) + const gs = await import(`${SRC}global-settings.ts?d=ept-auth`) + const { fetchImpl } = scriptedFetch([ + () => + jsonResponse(200, { + session_id: "SESS-AUTO", + verification_url: "https://broker.test/verify/auto", + }), + () => + jsonResponse(200, { + status: "ready", + provider: "github", + host: "github.com", + account: "octocat", + access_token: "gho_just_obtained", + }), + ]) + const tok = await mod.ensureProviderToken("github", fakeDeps(fetchImpl)) + assert.ok(tok, "auto-auth should have obtained a token") + assert.equal(tok.access_token, "gho_just_obtained") + // persisted for next time + assert.equal( + gs.readProviderToken("github").access_token, + "gho_just_obtained", + ) + restoreProxy() + }) +}) + +test("ensureProviderToken: broker unreachable → returns null (never throws), so the caller can fall back", async () => { + await withTempGlobal(async () => { + process.env.HAIKU_AUTH_PROXY_URL = "https://broker.test" + const mod = await import( + `${SRC}tools/orchestrator/haiku_auth_login.ts?d=ept-down` + ) + // /cli/start 500 (broker down / undeployed) → login throws internally → + // ensureProviderToken swallows it and returns null. + const { fetchImpl } = scriptedFetch([() => jsonResponse(500, {})]) + const tok = await mod.ensureProviderToken("github", fakeDeps(fetchImpl)) + assert.equal(tok, null) + restoreProxy() + }) +}) diff --git a/packages/haiku/test/closing-brief-post.test.mjs b/packages/haiku/test/closing-brief-post.test.mjs new file mode 100644 index 000000000..7906e18a1 --- /dev/null +++ b/packages/haiku/test/closing-brief-post.test.mjs @@ -0,0 +1,468 @@ +#!/usr/bin/env npx tsx +// closing-brief-post.test.mjs — the POST-execute closing BRIEF (#17). +// +// The pre-execute BRIEF ("this is what I am going to do") is written in the +// review walk, keyed on BRIEF.md absence (covered in cursor-walk.test.mjs). +// This file pins the POST-execute counterpart ("this is what I did"): the +// engine rewrites the SAME BRIEF.md once the work has landed, ONCE, keyed on +// the brief's OWN frontmatter `phase:` (BRIEF.md already exists from the pre +// firing, so absence can't gate the closing brief — and a sibling marker file +// could drift from the content, so the signal lives inside the artifact). The +// pre brief stamps `phase: pre`; the closing brief rewrites the file and +// stamps `phase: post`; once the on-disk brief reads `post`, the gate is off. +// +// Per the 2-gate design there are TWO reachable surfaces, because once a +// stage's `user` approval is signed its units are complete and +// findCurrentStage advances PAST it — the per-stage cursor walk never runs for +// the just-finished stage again. So: +// +// (A) NON-AUTOPILOT — user approval still PENDING. The stage is the frontier +// stage; the cursor walk reaches step 8's approval track, and right +// before the `user_gate` return (every adversarial approval + the +// quality gate already signed) it emits write_brief(post). The human +// then reviews the post-execution summary. Asserted via the cursor walk +// (cursorOnStageBranch). +// +// (B) AUTOPILOT / PRIOR-STAGE-MERGE — user approval signed (autopilot omits +// the `user` role; or a non-frontier prior stage is merging). The stage +// completes, findCurrentStage advances, and run_next synthesizes +// complete_stage(stage). The closing brief fires there, BEFORE the +// observations gate. Asserted via the real haiku_run_next handler +// (runNextOnce), the same surface the obs-gate test in +// cursor-walk.test.mjs uses. +// +// Studios are TWO-stage (design + build) so "design" is NON-terminal — a +// terminal stage routes to the intent-completion track, a separate scope that +// doesn't carry the per-stage closing brief. The observations gate has the +// same two-stage shape (cursor-walk.test.mjs). + +import assert from "node:assert" +import { execFileSync } from "node:child_process" +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { test } from "node:test" +import matter from "gray-matter" +import { + initTestRepo, + makeIntent, + makeStudio, + onStageBranch, + seedVerifiedElaboration, +} from "./_v4-fixtures.mjs" + +const HAS_GIT = (() => { + try { + execFileSync("git", ["--version"], { stdio: "ignore" }) + return true + } catch { + return false + } +})() + +async function withTmpRepo(slug, fn) { + const dir = mkdtempSync(join(tmpdir(), "haiku-closing-brief-")) + const stableCwd = tmpdir() + const origCwd = process.cwd() + try { + const repo = initTestRepo({ repoRoot: dir, slug }) + return await fn(repo) + } finally { + try { + process.chdir(origCwd) + } catch { + process.chdir(stableCwd) + } + rmSync(dir, { recursive: true, force: true }) + } +} + +// Build a unit file on the stage branch (mirrors cursor-walk's writeUnit). +function writeUnit(intentDir, stage, name, fm, body = "") { + const slug = intentDir.split("/").pop() ?? "" + const repoRoot = intentDir.split("/").slice(0, -3).join("/") + const path = join(intentDir, "stages", stage, "units", `${name}.md`) + onStageBranch(repoRoot, slug, stage, () => { + mkdirSync(join(intentDir, "stages", stage, "units"), { recursive: true }) + writeFileSync(path, matter.stringify(body || `# ${name}\n`, fm)) + }) + return path +} + +// Two-stage studio so "design" is non-terminal — the per-stage walk runs its +// tail (approval track → observations → closing brief → complete_stage). +function twoStageStudio(repoRoot) { + const stage = (name) => ({ + name, + hats: ["planner", "builder", "verifier"], + fix_hats: ["builder", "feedback-assessor"], + review: "ask", + review_agents: ["code-reviewer"], + }) + return makeStudio({ + repoRoot, + studio: "test", + stages: [stage("design"), stage("build")], + }) +} + +// Drive the cursor walk directly on the stage branch. dispatchOrchestratorAction +// reads the current working tree, so we check out the stage branch first (where +// writeUnit committed the unit) — exactly the branch the engine is on when it +// reaches the post-execute approval track. +async function cursorOnStageBranch(repoRoot, slug, stage) { + const origCwd = process.cwd() + process.chdir(repoRoot) + try { + execFileSync("git", ["checkout", "-q", `haiku/${slug}/${stage}`], { + cwd: repoRoot, + stdio: "ignore", + }) + const { clearStudioCache } = await import("../src/studio-reader.js") + const { dispatchOrchestratorAction } = await import( + "../src/orchestrator/workflow/run-tick.js" + ) + clearStudioCache() + return dispatchOrchestratorAction(slug, "") + } finally { + process.chdir(origCwd) + } +} + +// Drive the REAL haiku_run_next handler (not just the cursor walk) so the +// closing-brief gate that lives in run_next's complete_stage path — alongside +// the observations gate, not in derivePosition — is exercised. Mirrors the +// runNextOnce helper in cursor-walk.test.mjs. +async function runNextOnce(slug) { + const { orchestratorToolHandlers } = await import( + "../src/tools/orchestrator/index.js" + ) + const tool = orchestratorToolHandlers.get("haiku_run_next") + const resp = await tool.handle({ intent: slug }) + const txt = resp.content?.[0]?.text ?? "" + const m = txt.match(/```json\s*([\s\S]*?)\s*```/) + if (m) { + try { + return JSON.parse(m[1]) + } catch { + /* fall through */ + } + } + const head = txt.split("\n\n---")[0].trim() + try { + return JSON.parse(head) + } catch { + return { action: "unparsed", raw: txt.slice(0, 200) } + } +} + +// A unit signed on everything EXCEPT the user approval — the NON-AUTOPILOT +// precondition. reviews fully signed (pre-execute review walk satisfied), +// approvals signed for every adversarial role + the quality gate but NOT +// `user`, so the post-execute approval walk stops at the user gate and the +// stage stays the frontier (findCurrentStage does NOT advance past it). +function signedExceptUser() { + return { + title: "u1", + depends_on: [], + started_at: "t", + iterations: [ + { hat: "planner", started_at: "t", completed_at: "t", result: "advance" }, + { hat: "builder", started_at: "t", completed_at: "t", result: "advance" }, + { hat: "verifier", started_at: "t", completed_at: "t", result: "advance" }, + ], + reviews: { + spec: { at: "t" }, + continuity: { at: "t" }, + "cross-stage-consistency": { at: "t" }, + "code-reviewer": { at: "t" }, + user: { at: "t" }, + }, + // Every adversarial approval + the quality gate is signed. ONLY the + // `user` approval is pending — so the post-execute approval walk + // (approvalRoles = [spec, continuity, cross-stage-consistency, + // code-reviewer, quality_gates, user]) stops at the user branch, and + // the stage stays the frontier (findCurrentStage does NOT advance). + approvals: { + spec: { at: "t" }, + continuity: { at: "t" }, + "cross-stage-consistency": { at: "t" }, + "code-reviewer": { at: "t" }, + quality_gates: { at: "t" }, + // user intentionally absent — the human gate is still pending. + }, + discovery: {}, + } +} + +// A fully-signed, quality-gated unit INCLUDING the user approval — the +// AUTOPILOT / prior-stage-merge precondition. With user signed the stage +// completes and findCurrentStage advances, so run_next synthesizes +// complete_stage(design) and the closing brief fires on that path. +function fullySignedUnit() { + const u = signedExceptUser() + u.approvals.user = { at: "t" } + return u +} + +// ── Surface A: non-autopilot, before the user gate ────────────────────────── + +test("cursor (non-autopilot): adversarial+qg signed, user pending, BRIEF exists, no marker → write_brief(post) before user_gate", async () => { + if (!HAS_GIT) return + await withTmpRepo("closing-brief", async ({ repoRoot, intentDir, slug }) => { + twoStageStudio(repoRoot) + makeIntent({ intentDir, slug, studio: "test" }) + seedVerifiedElaboration({ intentDir, stage: "design" }) + writeUnit(intentDir, "design", "unit-01", signedExceptUser()) + // BRIEF.md exists with `phase: pre` (the pre-execute brief wrote it) — + // so the closing-brief gate keys on the frontmatter not yet being post. + onStageBranch(repoRoot, slug, "design", () => { + writeFileSync( + join(intentDir, "stages", "design", "BRIEF.md"), + matter.stringify("# Brief\nWhat this stage will deliver.\n", { phase: "pre" }), + ) + }) + + const action = await cursorOnStageBranch(repoRoot, slug, "design") + assert.strictEqual( + action.action, + "write_brief", + `expected closing write_brief before the user gate; got: ${action.action} — ${action.message ?? ""}`, + ) + assert.strictEqual(action.phase, "post", "closing brief must carry phase: post") + assert.strictEqual(action.stage, "design") + }) +}) + +test("cursor (non-autopilot): once BRIEF.md frontmatter is phase: post → falls through to user_gate, does NOT re-emit write_brief", async () => { + if (!HAS_GIT) return + await withTmpRepo("closing-brief-done", async ({ repoRoot, intentDir, slug }) => { + twoStageStudio(repoRoot) + makeIntent({ intentDir, slug, studio: "test" }) + seedVerifiedElaboration({ intentDir, stage: "design" }) + writeUnit(intentDir, "design", "unit-01", signedExceptUser()) + onStageBranch(repoRoot, slug, "design", () => { + // BRIEF.md already rewritten with `phase: post` — the in-content + // signal that flips the closing-brief gate off. + writeFileSync( + join(intentDir, "stages", "design", "BRIEF.md"), + matter.stringify("# Brief\nWhat this stage delivered.\n", { phase: "post" }), + ) + }) + + const action = await cursorOnStageBranch(repoRoot, slug, "design") + assert.notStrictEqual( + action.action, + "write_brief", + `phase: post stamped: closing brief must not re-emit; got: ${action.action}`, + ) + assert.strictEqual( + action.action, + "user_gate", + `expected the human approval gate after the closing brief is finalized; got: ${action.action} — ${action.message ?? ""}`, + ) + assert.strictEqual(action.stage, "design") + }) +}) + +test("cursor (non-autopilot): closing brief opt-out (brief: false) → straight to user_gate, no closing write_brief", async () => { + if (!HAS_GIT) return + await withTmpRepo("closing-brief-opt", async ({ repoRoot, intentDir, slug }) => { + twoStageStudio(repoRoot) + makeIntent({ intentDir, slug, studio: "test", extraFm: { brief: false } }) + seedVerifiedElaboration({ intentDir, stage: "design" }) + writeUnit(intentDir, "design", "unit-01", signedExceptUser()) + // brief:false — no BRIEF.md; the closing-brief gate is off entirely. + const action = await cursorOnStageBranch(repoRoot, slug, "design") + assert.strictEqual( + action.action, + "user_gate", + `brief:false must skip the closing brief; got: ${action.action}`, + ) + }) +}) + +// ── Surface B: autopilot / prior-stage merge, in run_next's complete_stage ── + +test("run_next (autopilot): user signed, BRIEF exists, no marker → write_brief(post) before the stage merges", async () => { + if (!HAS_GIT) return + await withTmpRepo("closing-brief-merge", async ({ repoRoot, intentDir, slug }) => { + twoStageStudio(repoRoot) + // autotune:true => reflection on, so the observations gate sits just + // AFTER the closing-brief gate — proving the brief fires first. + makeIntent({ intentDir, slug, studio: "test", extraFm: { autotune: true } }) + seedVerifiedElaboration({ intentDir, stage: "design" }) + writeUnit(intentDir, "design", "unit-01", fullySignedUnit()) + onStageBranch(repoRoot, slug, "design", () => { + writeFileSync( + join(intentDir, "stages", "design", "BRIEF.md"), + matter.stringify("# Brief\nWhat this stage will deliver.\n", { phase: "pre" }), + ) + }) + + // Sit on the design branch where the signed unit lives, like a real run. + process.chdir(repoRoot) + execFileSync("git", ["checkout", "-q", `haiku/${slug}/design`], { + cwd: repoRoot, + stdio: "ignore", + }) + + // design is fully signed and build is next → run_next synthesizes + // complete_stage(design); the closing-brief gate must fire before the + // observations gate and before the merge. + const first = await runNextOnce(slug) + assert.strictEqual( + first.action, + "write_brief", + `expected closing write_brief before merge; got: ${first.action} — ${JSON.stringify(first).slice(0, 200)}`, + ) + assert.strictEqual(first.phase, "post", "closing brief must carry phase: post") + assert.strictEqual(first.stage, "design") + }) +}) + +test("run_next (autopilot): once BRIEF.md frontmatter is phase: post → closing brief does NOT re-emit; the observations gate takes over", async () => { + if (!HAS_GIT) return + await withTmpRepo("closing-brief-merge-done", async ({ repoRoot, intentDir, slug }) => { + twoStageStudio(repoRoot) + makeIntent({ intentDir, slug, studio: "test", extraFm: { autotune: true } }) + seedVerifiedElaboration({ intentDir, stage: "design" }) + writeUnit(intentDir, "design", "unit-01", fullySignedUnit()) + onStageBranch(repoRoot, slug, "design", () => { + // BRIEF.md already rewritten with `phase: post` — the in-content + // signal that flips the closing-brief gate off on the merge path. + writeFileSync( + join(intentDir, "stages", "design", "BRIEF.md"), + matter.stringify("# Brief\nWhat this stage delivered.\n", { phase: "post" }), + ) + }) + + process.chdir(repoRoot) + execFileSync("git", ["checkout", "-q", `haiku/${slug}/design`], { + cwd: repoRoot, + stdio: "ignore", + }) + + // marker stamped → the closing brief is done; the next gate on the + // complete_stage path (observations, since reflection is on) takes over. + const action = await runNextOnce(slug) + assert.notStrictEqual( + action.action, + "write_brief", + `marker stamped: closing brief must not re-emit on the merge path; got: ${action.action}`, + ) + assert.strictEqual( + action.action, + "record_observations", + `expected the observations gate after the closing brief is finalized; got: ${action.action} — ${JSON.stringify(action).slice(0, 200)}`, + ) + assert.strictEqual(action.stage, "design") + }) +}) + +// ── Regression: the PRE-execute brief still fires as before ───────────────── + +test("cursor: pre-execute brief still fires with phase: pre (no regression)", async () => { + if (!HAS_GIT) return + await withTmpRepo("pre-brief-regression", async ({ repoRoot, intentDir, slug }) => { + twoStageStudio(repoRoot) + makeIntent({ intentDir, slug, studio: "test" }) + seedVerifiedElaboration({ intentDir, stage: "design" }) + writeUnit(intentDir, "design", "unit-01", { + title: "u1", + depends_on: [], + started_at: null, + iterations: [], + reviews: { + spec: { at: "t" }, + continuity: { at: "t" }, + "cross-stage-consistency": { at: "t" }, + "code-reviewer": { at: "t" }, + }, + approvals: {}, + discovery: {}, + }) + const action = await cursorOnStageBranch(repoRoot, slug, "design") + assert.strictEqual( + action.action, + "write_brief", + `pre-execute brief must still fire; got: ${action.action} — ${action.message ?? ""}`, + ) + assert.strictEqual(action.phase, "pre", "pre-execute brief must carry phase: pre") + assert.strictEqual(action.stage, "design") + }) +}) + +// existsSync imported for parity with the run_next surface helpers; referenced +// here to keep the import meaningful if future assertions check landed files. +void existsSync + +// ── haiku_write_brief tool: engine-owned phase determination ────────────── + +test("haiku_write_brief: body-only — engine resolves intent+stage, stamps pre then post", async () => { + if (!HAS_GIT) return + await withTmpRepo("write-brief-tool", async ({ repoRoot, intentDir, slug }) => { + twoStageStudio(repoRoot) + makeIntent({ intentDir, slug, studio: "test" }) + seedVerifiedElaboration({ intentDir, stage: "design" }) + const { default: writeBrief } = await import( + "../src/tools/orchestrator/haiku_write_brief.ts" + ) + const briefPath = join(intentDir, "stages", "design", "BRIEF.md") + const origCwd = process.cwd() + // On `haiku//main` (initTestRepo's checkout) the engine resolves + // the intent from the branch and the stage from the cursor — the agent + // passes ONLY the body. + process.chdir(repoRoot) + try { + const r1 = JSON.parse( + writeBrief.handle({ body: "# Brief\nWhat this stage will deliver.\n" }) + .content[0].text, + ) + assert.strictEqual(r1.slug, slug, "engine resolves intent from branch") + assert.strictEqual(r1.stage, "design", "engine resolves stage from cursor") + assert.strictEqual(r1.phase, "pre", "first write must be pre") + const fm1 = matter(readFileSync(briefPath, "utf8")).data + assert.strictEqual(fm1.phase, "pre", "on-disk frontmatter must be pre") + + // Second call: BRIEF.md exists → engine stamps phase: post. + const r2 = JSON.parse( + writeBrief.handle({ body: "# Brief\nWhat this stage delivered.\n" }) + .content[0].text, + ) + assert.strictEqual(r2.phase, "post", "rewrite must be post") + const parsed2 = matter(readFileSync(briefPath, "utf8")) + assert.strictEqual(parsed2.data.phase, "post", "frontmatter must flip to post") + assert.match(parsed2.content, /What this stage delivered/, "body must update") + } finally { + process.chdir(origCwd) + } + }) +}) + +test("haiku_write_brief: no intent on disk returns intent_not_found", async () => { + if (!HAS_GIT) return + // initTestRepo checks out `haiku//main` but we never makeIntent, so + // the branch resolves a slug with no intent.md behind it. + await withTmpRepo("write-brief-missing", async ({ repoRoot }) => { + const { default: writeBrief } = await import( + "../src/tools/orchestrator/haiku_write_brief.ts" + ) + const origCwd = process.cwd() + process.chdir(repoRoot) + try { + const resp = writeBrief.handle({ body: "# x\n" }) + assert.strictEqual(resp.isError, true) + assert.match(resp.content[0].text, /intent_not_found/) + } finally { + process.chdir(origCwd) + } + }) +}) diff --git a/packages/haiku/test/cross-stage-fb-rewalk.test.mjs b/packages/haiku/test/cross-stage-fb-rewalk.test.mjs index 8db440e60..a62ba6e36 100644 --- a/packages/haiku/test/cross-stage-fb-rewalk.test.mjs +++ b/packages/haiku/test/cross-stage-fb-rewalk.test.mjs @@ -594,3 +594,212 @@ test("e2e (interpretation B): FB on s1 lands AFTER s4 merged → cursor walks Tr ) }) }) + +// Bug report (merge-trains-integration-gate, 2026-05-28) failure-mode 3: an +// AGENT finding on an EARLIER stage, MANUALLY rejected via haiku_feedback_reject +// while a LATER stage is active. The reject lands on the FB's stage branch, but +// the engine reads the active stage branch — so the reject must still propagate +// (the earlier branch goes ahead-of-main → the cursor rewinds + re-merges it). +// Pre-fix this stranded the reject and the engine kept re-walking an FB that +// looked open on the active branch. Asserts the manual reject is NOT invisible: +// the pipeline recovers and seals, and the FB ends terminal-rejected. +test( + "e2e (FM3): manual reject of an earlier-stage agent FB while a later stage is active → reject propagates, pipeline seals", + { timeout: 30000 }, + async () => { + if (!HAS_GIT) return + await withRepo("cross-fb-reject", async ({ repoRoot, intentDir, slug }) => { + buildFourStageStudio(repoRoot) + makeIntent({ + intentDir, + slug, + studio: "fb4", + mode: "continuous", + extraFm: { stages: ["s1", "s2", "s3", "s4"] }, + }) + const { handleStateTool } = await import("../src/state-tools.ts") + + const seen = [] + let injectedFb = false + let rejected = false + let s3MergedBeforeInject = false + const MAX_TICKS = 400 + + for (let i = 0; i < MAX_TICKS; i++) { + const action = await runTick(slug) + seen.push(`${action.action}/${action.stage ?? ""}`) + + if ( + !s3MergedBeforeInject && + action.action === "complete_stage" && + action.stage === "s3" + ) { + applyResponse(intentDir, action, repoRoot, slug) + s3MergedBeforeInject = true + continue + } + + // Once s4 is in flight, inject an AGENT FB on s1 and MANUALLY + // reject it (the bug's exact action — not the fix loop). + if ( + !injectedFb && + s3MergedBeforeInject && + action.action === "start_unit_hat" && + action.stage === "s4" + ) { + applyResponse(intentDir, action, repoRoot, slug) + makeFeedback({ + intentDir, + stage: "s1", + id: 1, + title: "spec misattribution on s1", + body: "agent finding the user judges stale after correcting the artifact", + origin: "adversarial-review", + author: "spec", + }) + injectedFb = true + const resp = handleStateTool("haiku_feedback_reject", { + intent: slug, + stage: "s1", + feedback_id: 1, + reason: "stale — the s1 artifact was corrected; finding no longer applies", + }) + assert.ok( + !resp.isError, + `reject failed: ${resp.content?.[0]?.text ?? ""}`, + ) + rejected = true + continue + } + + if (action.action === "sealed") break + applyResponse(intentDir, action, repoRoot, slug) + if (action.action === "seal_intent") { + const intentMd = join(intentDir, "intent.md") + const fm = readFm(intentMd) + writeFm(intentMd, { ...fm, sealed_at: new Date().toISOString() }) + } + } + + assert.ok(rejected, "FB never injected + rejected") + assert.ok( + seen[seen.length - 1].startsWith("sealed/"), + `manual reject stranded the FB — pipeline never sealed; recent: ${seen.slice(-12).join(" → ")}`, + ) + + // The reject must be VISIBLE on the engine's read surface — the FB + // ends terminal-rejected, not re-dispatched as open. + const fbDir = join(intentDir, "stages", "s1", "feedback") + const fbFiles = existsSync(fbDir) + ? readdirSync(fbDir).filter((f) => f.endsWith(".md")) + : [] + assert.ok(fbFiles.length >= 1, "FB file vanished from disk") + const fb = readFm(join(fbDir, fbFiles[0])) + assert.ok( + typeof fb.rejected_at === "string" && fb.rejected_at.length > 0, + `reject was invisible to the engine's read branch: ${JSON.stringify(fb)}`, + ) + }) + }, +) + +// User requirement (2026-05-29): feedback must stay ADDRESSABLE until the intent's +// work is merged into the default branch. Under git, "not merged" == pending_seal +// (work landed on the hub branch `haiku//main` but not yet on the repo +// default). So while HELD at pending_seal, a NEW open finding must (a) be +// accepted, and (b) preempt the seal — the engine must NOT seal past open +// feedback, and must re-seal only once it's addressed AND the merge lands. +test( + "e2e (pending_seal): an open finding left while awaiting the default-branch merge blocks the seal, then the intent seals once addressed + merged", + { timeout: 30000 }, + async () => { + if (!HAS_GIT) return + await withRepo("pending-seal-fb", async ({ repoRoot, intentDir, slug }) => { + buildFourStageStudio(repoRoot) + makeIntent({ + intentDir, + slug, + studio: "fb4", + mode: "continuous", + extraFm: { stages: ["s1", "s2", "s3", "s4"] }, + }) + + const seen = [] + let sawPendingSeal = false + let heldFbInjected = false + let sealedWhileFbOpen = false + const MAX_TICKS = 500 + + for (let i = 0; i < MAX_TICKS; i++) { + const action = await runTick(slug) + seen.push(`${action.action}/${action.stage ?? ""}`) + + if (action.action === "pending_seal" && !heldFbInjected) { + // HELD at pending_seal: hub branch not yet on the default + // branch. Leave an open finding here — it must be accepted + // AND must keep the engine from sealing. + sawPendingSeal = true + makeFeedback({ + intentDir, + stage: "s2", + id: 1, + title: "post-completion gap noticed before merge", + body: "user leaves feedback while the intent awaits the default-branch merge", + origin: "user-chat", + author: "user", + }) + heldFbInjected = true + // Do NOT deliver yet. Next tick MUST NOT seal — the open FB + // preempts (Track B re-routes to s2's fix loop). + continue + } + + if (action.action === "sealed") { + if ( + heldFbInjected && + existsSync(join(intentDir, "stages", "s2", "feedback")) + ) { + const open = readdirSync( + join(intentDir, "stages", "s2", "feedback"), + ).some((f) => { + if (!f.endsWith(".md")) return false + const d = readFm(join(intentDir, "stages", "s2", "feedback", f)) + return !d.closed_at && !d.rejected_at + }) + if (open) sealedWhileFbOpen = true + } + break + } + applyResponse(intentDir, action, repoRoot, slug) + if (action.action === "seal_intent") { + const intentMd = join(intentDir, "intent.md") + const fm = readFm(intentMd) + writeFm(intentMd, { ...fm, sealed_at: new Date().toISOString() }) + } + } + + assert.ok( + sawPendingSeal, + `never reached pending_seal; recent: ${seen.slice(-10).join(" → ")}`, + ) + // The finding left at pending_seal pulled the cursor back into a fix + // cycle — proving feedback is still addressable pre-merge. + assert.ok( + seen.some( + (s) => + s.startsWith("start_feedback_hat/") || s.startsWith("close_feedback/"), + ), + `open feedback at pending_seal was never processed; recent: ${seen.slice(-15).join(" → ")}`, + ) + assert.ok( + !sealedWhileFbOpen, + "engine sealed while a finding was still open — feedback was NOT addressable until merge", + ) + assert.equal( + seen[seen.length - 1].startsWith("sealed/"), + true, + `intent never sealed after the finding was addressed; recent: ${seen.slice(-12).join(" → ")}`, + ) + }) + }, +) diff --git a/packages/haiku/test/discovery-question-loop.test.mjs b/packages/haiku/test/discovery-question-loop.test.mjs index b085d392c..71b8bf6a5 100644 --- a/packages/haiku/test/discovery-question-loop.test.mjs +++ b/packages/haiku/test/discovery-question-loop.test.mjs @@ -65,7 +65,11 @@ async function withRepo(slug, fn) { } catch { process.chdir(tmpdir()) } - rmSync(root, { recursive: true, force: true }) + // maxRetries/retryDelay rides out the git-objects async-write race — + // a `git` background write (auto-gc / pack finalize) can drop a file + // into `.git/objects` between readdir and rmdir, which surfaces as + // `ENOTEMPTY` on a bare rmSync (CI-only flake, 2026-05-29). + rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }) } } diff --git a/packages/haiku/test/drop-stage-escape-branch.test.mjs b/packages/haiku/test/drop-stage-escape-branch.test.mjs new file mode 100644 index 000000000..6c19448fc --- /dev/null +++ b/packages/haiku/test/drop-stage-escape-branch.test.mjs @@ -0,0 +1,161 @@ +// drop-stage-escape-branch.test.mjs +// +// Regression for #22 (reported 2026-05-29): "I moved to the design stage and +// its branch, dropped the stage, was STILL ON THE BRANCH after the fix, and got +// stuck — couldn't rescue off of it." The drop deadlock (plan flip-flop) is +// fixed elsewhere; this pins the CHECKOUT guarantee: after dropping the optional +// stage you're parked on, you must end up OFF that branch, and a follow-up +// run_next must advance — never strand the user on a branch for a stage that no +// longer exists in the plan. +// +// Two paths: +// 1. Clean drop: on haiku//design, drop design → checkout lands on +// intent main, design branch reaped, run_next advances to development. +// 2. Wedged recovery: design already dropped from main's plan but the design +// branch still exists and is checked out (old-bug leftover / a switch that +// didn't land). run_next must realign the checkout OFF design. + +import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { dirname, join, resolve } from "node:path" +import { test } from "node:test" +import { fileURLToPath } from "node:url" + +const __dirname = dirname(fileURLToPath(import.meta.url)) +process.env.CLAUDE_PLUGIN_ROOT = resolve(__dirname, "..", "..", "..", "plugin") +const SRC = new URL("../src/", import.meta.url).pathname + +const HAS_GIT = (() => { + try { + execFileSync("git", ["--version"], { stdio: "ignore" }) + return true + } catch { + return false + } +})() + +function git(cwd, ...args) { + execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] }) +} + +function intentMd(stages) { + return `--- +title: Drop escape fixture +studio: software +mode: continuous +stages: +${stages.map((s) => ` - ${s}`).join("\n")} +status: active +plugin_version: "10.0.0" +--- + +Body. +` +} + +function seedIntent(tmp, slug, stages) { + git(tmp, "init", "-q", "-b", "main") + git(tmp, "config", "user.email", "t@t.co") + git(tmp, "config", "user.name", "t") + git(tmp, "config", "commit.gpgsign", "false") + writeFileSync(join(tmp, "README.md"), "# test\n") + git(tmp, "add", "-A") + git(tmp, "commit", "-q", "-m", "base") + const intentMain = `haiku/${slug}/main` + git(tmp, "checkout", "-q", "-b", intentMain) + const iDir = join(tmp, ".haiku", "intents", slug) + mkdirSync(iDir, { recursive: true }) + writeFileSync(join(iDir, "intent.md"), intentMd(stages)) + git(tmp, "add", "-A") + git(tmp, "commit", "-q", "-m", "intent") + return iDir +} + +async function runNext(slug) { + const { orchestratorToolHandlers } = await import( + `${SRC}tools/orchestrator/index.ts` + ) + const tool = orchestratorToolHandlers.get("haiku_run_next") + return await tool.handle({ intent: slug }) +} + +test("drop the stage you're parked on → checkout leaves the dropped branch + run_next advances", async () => { + if (!HAS_GIT) return + const tmp = mkdtempSync(join(tmpdir(), "haiku-drop-escape-")) + const slug = "demo-escape" + seedIntent(tmp, slug, ["design", "development"]) + // Park on the optional stage's own branch — the state the keep-or-drop + // offer leaves the checkout in. + git(tmp, "branch", `haiku/${slug}/design`, `haiku/${slug}/main`) + git(tmp, "checkout", "-q", `haiku/${slug}/design`) + + const prev = process.cwd() + process.chdir(tmp) + try { + const { getCurrentBranch, branchExists } = await import( + `${SRC}git-worktree.ts` + ) + assert.equal(getCurrentBranch(), `haiku/${slug}/design`, "start on design") + + const dropTool = (await import(`${SRC}tools/orchestrator/haiku_drop_stage.ts`)) + .default + await dropTool.handle({ intent: slug, stage: "design" }) + + assert.notEqual( + getCurrentBranch(), + `haiku/${slug}/design`, + "after the drop the checkout MUST be off the dropped branch", + ) + assert.equal( + branchExists(`haiku/${slug}/design`), + false, + "the dropped branch is reaped", + ) + + // A follow-up tick advances rather than getting stuck on the dropped stage. + const resp = await runNext(slug) + const txt = resp.content?.[0]?.text ?? "" + assert.ok( + !/design/.test(getCurrentBranch()), + `run_next must not put us back on design; on ${getCurrentBranch()}`, + ) + assert.doesNotMatch( + txt, + /drop_stage|deadlock|loop_aborted/, + `run_next should advance cleanly, got: ${txt.slice(0, 200)}`, + ) + } finally { + process.chdir(prev) + } +}) + +test("wedged recovery: design dropped from main but checkout still on the design branch → run_next escapes it", async () => { + if (!HAS_GIT) return + const tmp = mkdtempSync(join(tmpdir(), "haiku-drop-wedge-")) + const slug = "demo-wedge" + // Intent main's plan ALREADY dropped design (only development remains) — + // but the design branch still exists and is the checkout. This is the + // "still on the branch, couldn't rescue off" state. + seedIntent(tmp, slug, ["development"]) + git(tmp, "branch", `haiku/${slug}/design`, `haiku/${slug}/main`) + git(tmp, "checkout", "-q", `haiku/${slug}/design`) + + const prev = process.cwd() + process.chdir(tmp) + try { + const { getCurrentBranch } = await import(`${SRC}git-worktree.ts`) + assert.equal(getCurrentBranch(), `haiku/${slug}/design`, "wedged on design") + + await runNext(slug) + + assert.notEqual( + getCurrentBranch(), + `haiku/${slug}/design`, + "run_next MUST move the checkout off the dropped/dangling design branch", + ) + } finally { + process.chdir(prev) + } +}) diff --git a/packages/haiku/test/drop-stage-reads-main-plan.test.mjs b/packages/haiku/test/drop-stage-reads-main-plan.test.mjs new file mode 100644 index 000000000..8c4500876 --- /dev/null +++ b/packages/haiku/test/drop-stage-reads-main-plan.test.mjs @@ -0,0 +1,102 @@ +// Layer 2: haiku_drop_stage must resolve the active stage from the CANONICAL +// plan (intent main's intent.stages), not the current-branch checkout. The +// old-bug divergence parks the checkout on a stage branch whose intent.stages +// already dropped the optional stage — so the current-branch active stage is +// the NEXT stage and the guard refused the drop (`drop_stage_not_active`), +// while haiku_run_next (reading main) kept arriving at the dropped stage. +// Reading main makes the two agree. +import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { test } from "node:test" + +const PLUGIN_ROOT = join(process.cwd(), "..", "..", "plugin") + +function sh(cmd, args, cwd) { + return execFileSync(cmd, args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }) +} + +function fm(stages) { + return [ + "---", + `title: "Test"`, + "studio: software", + `stages: [${stages.join(", ")}]`, + "mode: discrete", + "status: active", + "---", + "", + "# Test", + "", + ].join("\n") +} + +function setupRepo() { + const dir = mkdtempSync(join(tmpdir(), "haiku-drop-mainplan-")) + sh("git", ["init", "-b", "main"], dir) + sh("git", ["config", "user.email", "t@t.co"], dir) + sh("git", ["config", "user.name", "t"], dir) + return dir +} + +test("drop resolves active stage from intent main, not the diverged branch", async () => { + const dir = setupRepo() + const slug = "release-healthy-signals" + const iDir = join(dir, ".haiku", "intents", slug) + mkdirSync(join(iDir, "stages"), { recursive: true }) + + // `design` is optional in the software studio. Putting it FIRST in the plan + // makes it the active, unstarted stage immediately — no upstream stage to + // complete (the proven shape from drop-stage-lands-on-main). Intent main + // KEEPS design (the canonical plan the cursor reads). + writeFileSync(join(iDir, "intent.md"), fm(["design", "product"])) + sh("git", ["add", "-A"], dir) + sh("git", ["commit", "-m", "seed main with design"], dir) + sh("git", ["branch", `haiku/${slug}/main`], dir) + + // Stage branch where the OLD buggy drop landed: design removed from + // intent.stages on the BRANCH only. Reading this checkout, the active stage + // is `product` — so the pre-fix guard refused the drop while the cursor + // (reading main) kept arriving at design. We park the checkout here. + sh("git", ["branch", `haiku/${slug}/product`], dir) + sh("git", ["checkout", `haiku/${slug}/product`], dir) + writeFileSync(join(iDir, "intent.md"), fm(["product"])) + sh("git", ["add", "-A"], dir) + sh("git", ["commit", "-m", "old-bug: dropped design on stage branch"], dir) + + const prevCwd = process.cwd() + const prevPlugin = process.env.CLAUDE_PLUGIN_ROOT + process.chdir(dir) + process.env.CLAUDE_PLUGIN_ROOT = PLUGIN_ROOT + try { + const mod = await import( + `../src/tools/orchestrator/haiku_drop_stage.ts?d=${Date.now()}` + ) + // Parked on the product branch (current-branch active stage is + // `product`). With the canonical-main read, design IS the active stage, + // so the drop must be accepted — not `drop_stage_not_active`. + const res = await mod.default.handle({ intent: slug, stage: "design" }) + const payload = JSON.parse(res.content[0].text) + assert.equal( + payload.action, + "stage_dropped", + `expected drop accepted; got ${JSON.stringify(payload)}`, + ) + // And the drop lands on intent main. + const mainIntent = sh( + "git", + ["show", `haiku/${slug}/main:.haiku/intents/${slug}/intent.md`], + dir, + ) + assert.doesNotMatch(mainIntent, /stages:.*design/) + } finally { + process.chdir(prevCwd) + process.env.CLAUDE_PLUGIN_ROOT = prevPlugin + } +}) diff --git a/packages/haiku/test/e2e-mode-coverage.test.mjs b/packages/haiku/test/e2e-mode-coverage.test.mjs index dd52a9420..2d34aa1c5 100644 --- a/packages/haiku/test/e2e-mode-coverage.test.mjs +++ b/packages/haiku/test/e2e-mode-coverage.test.mjs @@ -291,9 +291,17 @@ function applyResponse(intentDir, action, root, slug) { } case "write_brief": { // Briefer stand-in: write the user-facing BRIEF.md so the cursor - // advances past the pre-execute brief step (to the user gate, or - // straight to execute in auto/autopilot). - writeFileSync(join(stageDir, "BRIEF.md"), "# Brief (test fixture)\n") + // advances past the brief step. Mirror haiku_write_brief's + // engine-owned phase rule — absent → pre, present → post — and + // stamp it via gray-matter (never a hand-rolled `---` block). + // `stageOwesClosingBrief` gates on `phase: post`, so a brief without + // it loops forever. + const briefFile = join(stageDir, "BRIEF.md") + const phase = existsSync(briefFile) ? "post" : "pre" + writeFileSync( + briefFile, + matter.stringify("# Brief (test fixture)\n", { phase }), + ) break } case "dispatch_review": { diff --git a/packages/haiku/test/fb-cursor-stuck-closed-bug.test.mjs b/packages/haiku/test/fb-cursor-stuck-closed-bug.test.mjs index 2e402bc86..46dd2ae25 100644 --- a/packages/haiku/test/fb-cursor-stuck-closed-bug.test.mjs +++ b/packages/haiku/test/fb-cursor-stuck-closed-bug.test.mjs @@ -22,12 +22,7 @@ // the dispatch refuses to spawn a subagent that would loop. import assert from "node:assert/strict" -import { - mkdirSync, - mkdtempSync, - rmSync, - writeFileSync, -} from "node:fs" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { dirname, join, resolve } from "node:path" import { test } from "node:test" @@ -144,6 +139,76 @@ test("cursor skips closed FB via closed_by fix-loop fallback", async () => { } }) +test("cursor skips REJECTED FB via rejected_at — report 20260528", async () => { + // Bug haiku-bug-merge-trains-20260528: a valid-and-fixed finding closed + // with haiku_feedback_reject stamps `rejected_at` (the v8 terminal signal) + // and NO closed_at / no `status` key. The open-feedback walk must treat + // rejected_at as terminal and skip it — else it re-dispatches + // start_feedback_hat forever while the classifier refuses ("already + // rejected, terminal"). Locks isFbTerminal's rejected_at branch. + const { __testOnly } = await importCursor() + const tmp = mkdtempSync(join(tmpdir(), "haiku-fb-rej-")) + try { + const fbPath = join(tmp, "010-fb.md") + writeFbFile( + fbPath, + [ + // Exactly what haiku_feedback_reject writes: rejected_at, no + // closed_at, no `status` key (stripped by normalizeLegacy…). + "rejected_at: '2026-05-28T14:46:18.472Z'", + "author_type: agent", + "targets:", + " unit: unit-01", + " invalidates: [spec]", + ].join("\n"), + ) + const action = __testOnly.nextActionForFeedback( + "security", + fbPath, + "software", + ) + assert.equal( + action, + null, + `a rejected FB (rejected_at set) MUST be skipped — re-dispatching it deadlocks the classifier. got: ${JSON.stringify(action)}`, + ) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } +}) + +test("cursor skips REJECTED FB when rejected_at is a Date (unquoted YAML)", async () => { + // Same Date-vs-string trap that bit closed_at: an unquoted ISO rejected_at + // parses as a Date. isFbTerminal must accept both forms. + const { __testOnly } = await importCursor() + const tmp = mkdtempSync(join(tmpdir(), "haiku-fb-rej-")) + try { + const fbPath = join(tmp, "011-fb.md") + writeFbFile( + fbPath, + [ + "rejected_at: 2026-05-28T14:46:18.472Z", + "author_type: agent", + "targets:", + " unit: unit-01", + " invalidates: [spec]", + ].join("\n"), + ) + const action = __testOnly.nextActionForFeedback( + "security", + fbPath, + "software", + ) + assert.equal( + action, + null, + `rejected FB with Date-typed rejected_at MUST be skipped. got: ${JSON.stringify(action)}`, + ) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } +}) + test("start_feedback_hat dispatch defensively filters closed FBs on disk", async () => { // Even if the cursor regresses and emits a dispatch for a closed // FB, the prompt builder reads the file and filters it out before diff --git a/packages/haiku/test/global-settings.test.mjs b/packages/haiku/test/global-settings.test.mjs new file mode 100644 index 000000000..bc872eaf0 --- /dev/null +++ b/packages/haiku/test/global-settings.test.mjs @@ -0,0 +1,196 @@ +// global-settings.test.mjs — the GLOBAL token store (~/.haiku/settings.json) +// + the haiku_auth_status / haiku_auth_logout MCP tools. +// +// HAIKU_GLOBAL_DIR is pointed at a temp dir per test so the real user file is +// never touched. Covers: round-trip, clear, status hides token values + reports +// expiry, corrupt-file tolerance, 0600 perms, and the two tools' contracts. + +import assert from "node:assert/strict" +import { + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { test } from "node:test" + +const SRC = new URL("../src/", import.meta.url).pathname + +function withTempGlobal(fn) { + const dir = mkdtempSync(join(tmpdir(), "haiku-global-")) + const prev = process.env.HAIKU_GLOBAL_DIR + process.env.HAIKU_GLOBAL_DIR = dir + return Promise.resolve(fn(dir)).finally(() => { + if (prev === undefined) delete process.env.HAIKU_GLOBAL_DIR + else process.env.HAIKU_GLOBAL_DIR = prev + rmSync(dir, { recursive: true, force: true }) + }) +} + +const TOKEN = { + access_token: "ghs_secret_value", + refresh_token: "ghr_refresh", + scopes: ["repo"], + account: "octocat", + host: "github.com", + obtained_at: "2026-05-28T00:00:00.000Z", +} + +test("writeProviderToken → readProviderToken round-trips", async () => { + await withTempGlobal(async () => { + const gs = await import(`${SRC}global-settings.ts`) + assert.equal(gs.readProviderToken("github"), null) + gs.writeProviderToken("github", TOKEN) + assert.deepEqual(gs.readProviderToken("github"), TOKEN) + // independent slot + assert.equal(gs.readProviderToken("gitlab"), null) + }) +}) + +test("clearProviderToken reports was-connected + removes", async () => { + await withTempGlobal(async () => { + const gs = await import(`${SRC}global-settings.ts?d=clear`) + gs.writeProviderToken("gitlab", { ...TOKEN, host: "gitlab.com" }) + assert.equal(gs.clearProviderToken("gitlab"), true) + assert.equal(gs.readProviderToken("gitlab"), null) + // idempotent + assert.equal(gs.clearProviderToken("gitlab"), false) + }) +}) + +test("listConnectedProviders hides token values + reports expiry", async () => { + await withTempGlobal(async () => { + const gs = await import(`${SRC}global-settings.ts?d=list`) + gs.writeProviderToken("github", { + ...TOKEN, + expires_at: "2000-01-01T00:00:00.000Z", // past → expired + }) + gs.writeProviderToken("gitlab", { + access_token: "glpat", + host: "gitlab.com", + obtained_at: "2026-05-28T00:00:00.000Z", + // no expires_at → non-expiring + }) + const list = gs.listConnectedProviders() + const gh = list.find((p) => p.provider === "github") + const gl = list.find((p) => p.provider === "gitlab") + assert.ok(gh && gl) + // SAFE fields only — never the secret + const serialized = JSON.stringify(list) + assert.ok(!serialized.includes("ghs_secret_value")) + assert.ok(!serialized.includes("ghr_refresh")) + assert.ok(!serialized.includes("glpat")) + assert.equal(gh.account, "octocat") + assert.deepEqual(gh.scopes, ["repo"]) + assert.equal(gh.expired, true) + assert.equal(gl.expired, false) // null expires_at = non-expiring + }) +}) + +test("corrupt settings file is tolerated (no throw)", async () => { + await withTempGlobal(async (dir) => { + writeFileSync(join(dir, "settings.json"), "{ not valid json ") + const gs = await import(`${SRC}global-settings.ts?d=corrupt`) + assert.equal(gs.readProviderToken("github"), null) + assert.deepEqual(gs.listConnectedProviders(), []) + // a write recovers — overwrites the corrupt file + gs.writeProviderToken("github", TOKEN) + assert.deepEqual(gs.readProviderToken("github"), TOKEN) + }) +}) + +test("settings file is written 0600", async () => { + await withTempGlobal(async (dir) => { + const gs = await import(`${SRC}global-settings.ts?d=perms`) + gs.writeProviderToken("github", TOKEN) + const mode = statSync(join(dir, "settings.json")).mode & 0o777 + assert.equal(mode, 0o600, `expected 0600, got ${mode.toString(8)}`) + }) +}) + +test("an unknown top-level key is preserved (forward-compat)", async () => { + await withTempGlobal(async (dir) => { + writeFileSync( + join(dir, "settings.json"), + JSON.stringify({ futureKey: { a: 1 } }), + ) + const gs = await import(`${SRC}global-settings.ts?d=fwd`) + gs.writeProviderToken("github", TOKEN) + const raw = JSON.parse(readFileSync(join(dir, "settings.json"), "utf8")) + assert.deepEqual(raw.futureKey, { a: 1 }) + assert.equal(raw.providers.github.access_token, "ghs_secret_value") + }) +}) + +// ── tools ── + +test("haiku_auth_status: connected=false when empty, never leaks tokens", async () => { + await withTempGlobal(async () => { + const tool = (await import(`${SRC}tools/orchestrator/haiku_auth_status.ts`)) + .default + const empty = JSON.parse((await tool.handle({})).content[0].text) + assert.equal(empty.ok, true) + assert.equal(empty.connected, false) + assert.deepEqual(empty.providers, []) + + const gs = await import(`${SRC}global-settings.ts?d=toolstatus`) + gs.writeProviderToken("github", TOKEN) + const res = await tool.handle({}) + assert.ok(!res.content[0].text.includes("ghs_secret_value")) + const payload = JSON.parse(res.content[0].text) + assert.equal(payload.connected, true) + assert.equal(payload.providers[0].provider, "github") + assert.equal(payload.providers[0].account, "octocat") + assert.equal(payload.providers[0].expired, false) + }) +}) + +test("haiku_auth_status: provider filter narrows", async () => { + await withTempGlobal(async () => { + const gs = await import(`${SRC}global-settings.ts?d=toolfilter`) + gs.writeProviderToken("github", TOKEN) + gs.writeProviderToken("gitlab", { ...TOKEN, host: "gitlab.com" }) + const tool = (await import(`${SRC}tools/orchestrator/haiku_auth_status.ts`)) + .default + const gl = JSON.parse( + (await tool.handle({ provider: "gitlab" })).content[0].text, + ) + assert.equal(gl.providers.length, 1) + assert.equal(gl.providers[0].provider, "gitlab") + }) +}) + +test("haiku_auth_status: bad provider → input_invalid", async () => { + await withTempGlobal(async () => { + const tool = (await import(`${SRC}tools/orchestrator/haiku_auth_status.ts`)) + .default + const res = await tool.handle({ provider: "bitbucket" }) + assert.equal(res.isError, true) + assert.match(res.content[0].text, /haiku_auth_status_input_invalid/) + }) +}) + +test("haiku_auth_logout: clears + idempotent + requires provider", async () => { + await withTempGlobal(async () => { + const gs = await import(`${SRC}global-settings.ts?d=toollogout`) + gs.writeProviderToken("github", TOKEN) + const tool = (await import(`${SRC}tools/orchestrator/haiku_auth_logout.ts`)) + .default + const first = JSON.parse( + (await tool.handle({ provider: "github" })).content[0].text, + ) + assert.equal(first.was_connected, true) + assert.equal(gs.readProviderToken("github"), null) + const second = JSON.parse( + (await tool.handle({ provider: "github" })).content[0].text, + ) + assert.equal(second.was_connected, false) + // provider required + const missing = await tool.handle({}) + assert.equal(missing.isError, true) + assert.match(missing.content[0].text, /haiku_auth_logout_input_invalid/) + }) +}) diff --git a/packages/haiku/test/heal-optional-stage-divergence.test.mjs b/packages/haiku/test/heal-optional-stage-divergence.test.mjs new file mode 100644 index 000000000..77d08d835 --- /dev/null +++ b/packages/haiku/test/heal-optional-stage-divergence.test.mjs @@ -0,0 +1,118 @@ +// heal-optional-stage-divergence.test.mjs +// +// Layer 3 regression for the optional-stage drop deadlock (release-healthy- +// signals). A pre-2026-05-28 buggy haiku_drop_stage wrote the drop to the +// stage branch but never to intent main, so the cursor (reads main) kept +// re-arriving at a stage the branches had dropped. The pre-tick heal must +// detect that divergence and propagate the drop UP to intent main. +// +// Sets up the diverged state directly — intent main lists +// [inception, design, product], the working tree (a stage-branch checkout) +// lists [inception, product] with design unstarted — and asserts +// healOptionalStageDivergence removes `design` from intent main's plan. +// `design` is optional in the software studio, so it qualifies. + +import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { dirname, join, resolve } from "node:path" +import { test } from "node:test" +import { fileURLToPath } from "node:url" + +const __dirname = dirname(fileURLToPath(import.meta.url)) +process.env.CLAUDE_PLUGIN_ROOT = resolve(__dirname, "..", "..", "..", "plugin") + +function git(cwd, ...args) { + execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] }) +} + +function intentMd(stages) { + return `--- +title: Test intent +studio: software +mode: discrete +stages: [${stages.join(", ")}] +plugin_version: "10.0.0" +--- + +Body. +` +} + +test("healOptionalStageDivergence propagates a branch drop up to intent main", async () => { + const tmp = mkdtempSync(join(tmpdir(), "haiku-heal-")) + git(tmp, "init", "-q", "-b", "main") + git(tmp, "config", "user.email", "t@t.co") + git(tmp, "config", "user.name", "t") + + const slug = "release-healthy-signals" + const intentRel = join(".haiku", "intents", slug) + const intentDirAbs = join(tmp, intentRel) + mkdirSync(intentDirAbs, { recursive: true }) + + // Seed the repo with a base commit so branches can fork. + writeFileSync(join(tmp, "README.md"), "# test\n") + git(tmp, "add", "-A") + git(tmp, "commit", "-q", "-m", "base") + + // Intent main branch: plan still lists the optional `design` stage. This + // is the canonical fork source the cursor reads — it KEEPS design. + const intentMain = `haiku/${slug}/main` + git(tmp, "checkout", "-q", "-b", intentMain) + writeFileSync( + join(intentDirAbs, "intent.md"), + intentMd(["inception", "design", "product"]), + ) + git(tmp, "add", "-A") + git(tmp, "commit", "-q", "-m", "intent main with design") + + // Stage branch (forked from main) is where the OLD buggy drop landed: + // design removed from intent.stages on the BRANCH, never on main. We fork + // it and CHECK IT OUT so the working tree carries the diverged (no-design) + // plan while `git show :…` still reports design. That is the + // exact deadlock divergence the heal must detect and propagate up to main. + const stageBranch = `haiku/${slug}/product` + git(tmp, "checkout", "-q", "-b", stageBranch) + writeFileSync( + join(intentDirAbs, "intent.md"), + intentMd(["inception", "product"]), + ) + git(tmp, "add", "-A") + git(tmp, "commit", "-q", "-m", "old-bug: dropped design on stage branch") + + // Run the heal from inside the repo (cwd-driven, like a real tick). + const prevCwd = process.cwd() + process.chdir(tmp) + try { + const { healOptionalStageDivergence } = await import( + new URL( + "../src/orchestrator/workflow/heal-optional-stage-divergence.ts", + import.meta.url, + ).href + ) + const healed = healOptionalStageDivergence(slug, "software") + assert.deepEqual(healed, ["design"], "should report design as healed") + + // intent main's plan must no longer list design. + const mainRaw = execFileSync( + "git", + ["show", `${intentMain}:${join(intentRel, "intent.md")}`], + { cwd: tmp, encoding: "utf8" }, + ) + assert.ok( + !/stages:.*design/.test(mainRaw), + "design should be removed from intent main's stages", + ) + assert.ok( + /inception/.test(mainRaw) && /product/.test(mainRaw), + "mandatory stages should remain on intent main", + ) + + // Idempotent: a second run heals nothing. + const second = healOptionalStageDivergence(slug, "software") + assert.deepEqual(second, [], "second run is a no-op") + } finally { + process.chdir(prevCwd) + } +}) diff --git a/packages/haiku/test/intent-pr-and-stage-handoff.test.mjs b/packages/haiku/test/intent-pr-and-stage-handoff.test.mjs index 2599e3c26..0c99e6ba8 100644 --- a/packages/haiku/test/intent-pr-and-stage-handoff.test.mjs +++ b/packages/haiku/test/intent-pr-and-stage-handoff.test.mjs @@ -52,6 +52,24 @@ function test(name, fn) { } } +// Async variant: markPullRequestReady is async (it prefers the token-backed +// REST path, falling back to the CLI). Awaited at the call site (top-level +// await keeps these sequential with the sync `test` runs). +async function atest(name, fn) { + _resetIsGitRepoForTests() + try { + await fn() + passed++ + console.log(` ✓ ${name}`) + } catch (err) { + failed++ + console.log(` ✗ ${name}`) + console.log(` ${err.message}`) + if (err.stack) + console.log(` ${err.stack.split("\n").slice(1, 4).join("\n ")}`) + } +} + function withCwd(dir, fn) { const prev = process.cwd() process.chdir(dir) @@ -102,41 +120,54 @@ test("no-git-repo path returns benign message, no exception", () => { console.log("\n=== openStageDraftPullRequest ===") -test("no-git-repo path returns benign message, correct stage→intent-main branch pair", () => { - const dir = makeNonRepo() - withCwd(dir, () => { - const r = openStageDraftPullRequest({ slug: "test-intent", stage: "design" }) - // Stage branch → intent main (NOT repo default). - assert.strictEqual(r.branch, "haiku/test-intent/design") - assert.strictEqual(r.base, "haiku/test-intent/main") - assert.match(r.message, /Not a git repo/i) - assert.strictEqual(r.createdUrl, undefined) - }) - rmSync(dir, { recursive: true, force: true }) -}) +await atest( + "no-git-repo path returns benign message, correct stage→intent-main branch pair", + async () => { + const dir = makeNonRepo() + // openStageDraftPullRequest is async (REST-or-CLI), so we await it + // BEFORE restoring cwd — withCwd's sync finally would chdir back before + // the promise settled. + const prev = process.cwd() + process.chdir(dir) + try { + const r = await openStageDraftPullRequest({ + slug: "test-intent", + stage: "design", + }) + // Stage branch → intent main (NOT repo default). + assert.strictEqual(r.branch, "haiku/test-intent/design") + assert.strictEqual(r.base, "haiku/test-intent/main") + assert.match(r.message, /Not a git repo/i) + assert.strictEqual(r.createdUrl, undefined) + } finally { + process.chdir(prev) + } + rmSync(dir, { recursive: true, force: true }) + }, +) console.log("\n=== markPullRequestReady ===") -test("empty url returns benign error", () => { - const r = markPullRequestReady("") +await atest("empty url returns benign error", async () => { + const r = await markPullRequestReady("") assert.strictEqual(r.ok, false) assert.match(r.error, /empty url/i) }) -test("invalid url returns benign error (no throw)", () => { - const r = markPullRequestReady("not a url at all") +await atest("invalid url returns benign error (no throw)", async () => { + const r = await markPullRequestReady("not a url at all") assert.strictEqual(r.ok, false) assert.match(r.error, /not a valid URL/i) }) -test("unrecognised provider host surfaces in error", () => { - const r = markPullRequestReady("https://example.com/foo/bar") +await atest("unrecognised provider host surfaces in error", async () => { + const r = await markPullRequestReady("https://example.com/foo/bar") assert.strictEqual(r.ok, false) assert.match(r.error, /unrecognised provider host/i) }) -test("gitlab URL without iid returns parse error", () => { - const r = markPullRequestReady("https://gitlab.com/owner/project/-/branches") +await atest("gitlab URL without iid returns parse error", async () => { + const r = await markPullRequestReady("https://gitlab.com/owner/project/-/branches") assert.strictEqual(r.ok, false) assert.match(r.error, /could not parse MR iid/i) }) diff --git a/packages/haiku/test/optional-offer-holds-discovery.test.mjs b/packages/haiku/test/optional-offer-holds-discovery.test.mjs new file mode 100644 index 000000000..e3c39e425 --- /dev/null +++ b/packages/haiku/test/optional-offer-holds-discovery.test.mjs @@ -0,0 +1,101 @@ +// Layer 1: when the cursor fires the keep-or-drop offer on first arrival at +// an optional stage, it must NOT also surface discovery/decompose signals — +// booting discovery/decompose subagents before the keep-or-drop decision is +// made would do throwaway work on a stage the agent may immediately drop. +// The offer action carries ONLY the conversation-class signal(s); recording +// the conversation (writes elaboration.md) clears the one-shot offer, and the +// NEXT tick surfaces discovery+decompose normally (on keep) or nothing (on +// drop). See cursor.ts optional-offer branch. +import assert from "node:assert/strict" +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { test } from "node:test" + +const PLUGIN_ROOT = join(process.cwd(), "..", "..", "plugin") + +function setup(stages) { + const dir = mkdtempSync(join(tmpdir(), "haiku-opt-hold-")) + const slug = "t" + const iDir = join(dir, ".haiku", "intents", slug) + mkdirSync(join(iDir, "stages"), { recursive: true }) + writeFileSync( + join(iDir, "intent.md"), + [ + "---", + `title: "T"`, + "studio: software", + `stages: [${stages.join(", ")}]`, + "mode: discrete", + "status: active", + // Past the pre-intent intent.md substance gate (`verified_at` — see + // cursor.ts derivePosition ~2189) so derivePosition proceeds into the + // per-stage walk instead of returning the pre-intent elaborate_loop. + // QUOTED — gray-matter parses an unquoted ISO date as a Date object, + // and the gate checks `typeof verified_at === "string"`. + `verified_at: "2026-05-28T00:00:00.000Z"`, + "---", + "", + "# T", + "", + ].join("\n"), + ) + return { dir, slug, iDir } +} + +test("optional-offer action holds discovery/decompose signals", async () => { + // Put the optional `design` stage FIRST so it's the active, unstarted stage + // immediately — no upstream stage to complete (the proven shape from + // drop-stage-lands-on-main). First-arrival fires the keep-or-drop offer. + const { dir, slug, iDir } = setup(["design", "product"]) + const prevCwd = process.cwd() + const prevPlugin = process.env.CLAUDE_PLUGIN_ROOT + process.chdir(dir) + process.env.CLAUDE_PLUGIN_ROOT = PLUGIN_ROOT + try { + const { derivePosition } = await import( + `../src/orchestrator/workflow/cursor.ts?d=${Date.now()}` + ) + // derivePosition returns a CursorPosition ({ track, action }); the cursor + // action is on `.action`. signals_unmet holds SIGNAL OBJECTS + // ({ signal: "discovery", … }), not bare strings — membership is tested + // via .some(s => s.signal === …), not .includes(). + const pos = derivePosition({ slug, intentDir: iDir, studio: "software" }) + const action = pos.action + assert.equal(action.kind, "elaborate_loop") + assert.equal( + action.optional_offer, + true, + `expected the optional-stage keep-or-drop offer; got ${JSON.stringify(action)}`, + ) + assert.ok( + Array.isArray(action.signals_unmet), + "signals_unmet should be an array", + ) + const hasSignal = (name) => + action.signals_unmet.some((s) => s.signal === name) + // The decision is pending — discovery and decompose MUST be held back. + assert.ok( + !hasSignal("discovery"), + `offer must not surface 'discovery'; got ${JSON.stringify(action.signals_unmet)}`, + ) + assert.ok( + !hasSignal("decompose"), + `offer must not surface 'decompose'; got ${JSON.stringify(action.signals_unmet)}`, + ) + assert.ok( + !hasSignal("verify_decompose"), + `offer must not surface 'verify_decompose'; got ${JSON.stringify(action.signals_unmet)}`, + ) + // Only conversation-class signals remain (the gate that clears the offer). + for (const s of action.signals_unmet) { + assert.ok( + s.signal === "conversation" || s.signal === "verify_conversation", + `offer must carry only conversation-class signals; got ${JSON.stringify(action.signals_unmet)}`, + ) + } + } finally { + process.chdir(prevCwd) + process.env.CLAUDE_PLUGIN_ROOT = prevPlugin + } +}) diff --git a/packages/haiku/test/provider-rest.test.mjs b/packages/haiku/test/provider-rest.test.mjs new file mode 100644 index 000000000..6e364cc86 --- /dev/null +++ b/packages/haiku/test/provider-rest.test.mjs @@ -0,0 +1,196 @@ +// provider-rest.test.mjs — assert the token-backed PR/MR REST shape (GitHub +// pulls + GraphQL ready, GitLab merge_requests + Draft-prefix ready) through an +// injectable fetch. No network, no real provider. This codifies the REST +// contracts the engine drives when a stored provider token is present; the +// `gh`/`glab` CLI remains the integration-proven fallback (see git-worktree.ts). +// +// Imports the TS source via tsx (the test runner is `npx tsx`). + +import assert from "node:assert/strict" +import { test } from "node:test" + +const SRC = new URL("../src/", import.meta.url).pathname + +/** Build a fake fetch from a queue of response factories; records calls. */ +function scriptedFetch(handlers) { + const calls = [] + const fetchImpl = async (url, init) => { + calls.push({ url: String(url), init: init ?? {} }) + const handler = handlers.shift() + if (!handler) throw new Error(`unexpected fetch to ${url}`) + return handler(String(url), init) + } + return { fetchImpl, calls } +} + +function jsonResponse(status, body) { + return { ok: status >= 200 && status < 300, status, json: async () => body } +} + +const ghCtx = { + provider: "github", + host: "github.com", + owner: "gigsmart", + repo: "haiku-method", + token: "gho_secret", +} +const glCtx = { + provider: "gitlab", + host: "gitlab.com", + owner: "gigsmart", + repo: "haiku-method", + token: "glpat_secret", +} + +// ── GitHub create ────────────────────────────────────────────────── + +test("createPullRequestRest (github): no existing PR → POST /pulls, returns html_url", async () => { + const mod = await import(`${SRC}provider-rest.ts?d=ghcreate`) + const { fetchImpl, calls } = scriptedFetch([ + () => jsonResponse(200, []), // dedup list → empty + () => + jsonResponse(201, { + html_url: "https://github.com/gigsmart/haiku-method/pull/7", + }), + ]) + const out = await mod.createPullRequestRest( + ghCtx, + { branch: "haiku/x/main", mainline: "main", title: "T", body: "B", draft: true }, + fetchImpl, + ) + assert.equal(out.url, "https://github.com/gigsmart/haiku-method/pull/7") + // dedup hits api.github.com pulls with the head filter + assert.match(calls[0].url, /api\.github\.com\/repos\/gigsmart\/haiku-method\/pulls\?head=/) + // create is a POST carrying draft:true + bearer + assert.equal(calls[1].init.method, "POST") + assert.equal(calls[1].init.headers.authorization, "Bearer gho_secret") + const body = JSON.parse(calls[1].init.body) + assert.equal(body.draft, true) + assert.equal(body.head, "haiku/x/main") + assert.equal(body.base, "main") +}) + +test("createPullRequestRest (github): existing open PR → returns it, no create", async () => { + const mod = await import(`${SRC}provider-rest.ts?d=ghdedup`) + const { fetchImpl, calls } = scriptedFetch([ + () => + jsonResponse(200, [ + { html_url: "https://github.com/gigsmart/haiku-method/pull/3" }, + ]), + ]) + const out = await mod.createPullRequestRest( + ghCtx, + { branch: "b", mainline: "main", title: "T", body: "B", draft: false }, + fetchImpl, + ) + assert.equal(out.url, "https://github.com/gigsmart/haiku-method/pull/3") + assert.equal(calls.length, 1) // dedup only; no second POST +}) + +test("createPullRequestRest (github): create failure throws ProviderRestError", async () => { + const mod = await import(`${SRC}provider-rest.ts?d=ghfail`) + const { fetchImpl } = scriptedFetch([ + () => jsonResponse(200, []), + () => jsonResponse(422, { message: "validation failed" }), + ]) + await assert.rejects( + () => + mod.createPullRequestRest( + ghCtx, + { branch: "b", mainline: "main", title: "T", body: "B", draft: false }, + fetchImpl, + ), + (err) => err.code === "pr_create_github_failed", + ) +}) + +// ── GitHub mark-ready (GraphQL) ──────────────────────────────────── + +test("markPullRequestReadyRest (github): GET node_id → GraphQL mutation", async () => { + const mod = await import(`${SRC}provider-rest.ts?d=ghready`) + const { fetchImpl, calls } = scriptedFetch([ + () => jsonResponse(200, { node_id: "PR_nodeid_123" }), + () => jsonResponse(200, { data: { markPullRequestReadyForReview: { pullRequest: { id: "PR_nodeid_123" } } } }), + ]) + await mod.markPullRequestReadyRest( + ghCtx, + "https://github.com/gigsmart/haiku-method/pull/7", + fetchImpl, + ) + assert.match(calls[0].url, /\/repos\/gigsmart\/haiku-method\/pulls\/7$/) + assert.match(calls[1].url, /\/graphql$/) + const gql = JSON.parse(calls[1].init.body) + assert.match(gql.query, /markPullRequestReadyForReview/) + assert.equal(gql.variables.id, "PR_nodeid_123") +}) + +test("markPullRequestReadyRest (github): GraphQL errors throw", async () => { + const mod = await import(`${SRC}provider-rest.ts?d=ghreadyerr`) + const { fetchImpl } = scriptedFetch([ + () => jsonResponse(200, { node_id: "PR_x" }), + () => jsonResponse(200, { errors: [{ message: "not a draft" }] }), + ]) + await assert.rejects( + () => + mod.markPullRequestReadyRest( + ghCtx, + "https://github.com/gigsmart/haiku-method/pull/7", + fetchImpl, + ), + (err) => err.code === "pr_ready_github_graphql_error", + ) +}) + +// ── GitLab create ────────────────────────────────────────────────── + +test("createPullRequestRest (gitlab): draft prefixes title, POST merge_requests", async () => { + const mod = await import(`${SRC}provider-rest.ts?d=glcreate`) + const { fetchImpl, calls } = scriptedFetch([ + () => jsonResponse(200, []), // dedup + () => + jsonResponse(201, { + web_url: "https://gitlab.com/gigsmart/haiku-method/-/merge_requests/4", + }), + ]) + const out = await mod.createPullRequestRest( + glCtx, + { branch: "src", mainline: "main", title: "T", body: "B", draft: true }, + fetchImpl, + ) + assert.equal(out.url, "https://gitlab.com/gigsmart/haiku-method/-/merge_requests/4") + assert.match(calls[1].url, /\/api\/v4\/projects\/gigsmart%2Fhaiku-method\/merge_requests$/) + const body = JSON.parse(calls[1].init.body) + assert.equal(body.title, "Draft: T") // draft → prefix + assert.equal(body.source_branch, "src") + assert.equal(calls[1].init.headers.authorization, "Bearer glpat_secret") +}) + +// ── GitLab mark-ready (strip Draft: prefix) ──────────────────────── + +test("markPullRequestReadyRest (gitlab): strips Draft: prefix via PUT", async () => { + const mod = await import(`${SRC}provider-rest.ts?d=glready`) + const { fetchImpl, calls } = scriptedFetch([ + () => jsonResponse(200, { title: "Draft: My feature" }), + () => jsonResponse(200, { title: "My feature" }), + ]) + await mod.markPullRequestReadyRest( + glCtx, + "https://gitlab.com/gigsmart/haiku-method/-/merge_requests/4", + fetchImpl, + ) + assert.equal(calls[1].init.method, "PUT") + assert.equal(JSON.parse(calls[1].init.body).title, "My feature") +}) + +test("markPullRequestReadyRest (gitlab): already-ready title → no PUT", async () => { + const mod = await import(`${SRC}provider-rest.ts?d=glnoop`) + const { fetchImpl, calls } = scriptedFetch([ + () => jsonResponse(200, { title: "Already ready" }), + ]) + await mod.markPullRequestReadyRest( + glCtx, + "https://gitlab.com/gigsmart/haiku-method/-/merge_requests/4", + fetchImpl, + ) + assert.equal(calls.length, 1) // GET only; no PUT +}) diff --git a/packages/haiku/test/real-intent-dry-run.test.mjs b/packages/haiku/test/real-intent-dry-run.test.mjs index 44e8cfb74..98f64ef67 100644 --- a/packages/haiku/test/real-intent-dry-run.test.mjs +++ b/packages/haiku/test/real-intent-dry-run.test.mjs @@ -310,8 +310,17 @@ function applyResponse(intentDir, action, root, slug) { } case "write_brief": { // Briefer stand-in: write the user-facing BRIEF.md so the cursor - // advances past the pre-execute brief step. - writeFileSync(join(stageDir, "BRIEF.md"), "# Brief (test fixture)\n") + // advances past the brief step. Mirror haiku_write_brief's + // engine-owned phase rule — absent → pre, present → post — and + // stamp it via gray-matter (never a hand-rolled `---` block). + // `stageOwesClosingBrief` gates on `phase: post`, so a brief without + // it loops forever. + const briefFile = join(stageDir, "BRIEF.md") + const phase = existsSync(briefFile) ? "post" : "pre" + writeFileSync( + briefFile, + matter.stringify("# Brief (test fixture)\n", { phase }), + ) break } case "dispatch_review": { diff --git a/packages/haiku/test/statusline-links.test.mjs b/packages/haiku/test/statusline-links.test.mjs new file mode 100644 index 000000000..935189dab --- /dev/null +++ b/packages/haiku/test/statusline-links.test.mjs @@ -0,0 +1,175 @@ +// statusline-links.test.mjs — the haikumethod.ai deep-link builders + the +// OSC 8 hyperlink wrapping in the renderer. +// +// URL formats mirror the website's real routes (keyword-delimited browse +// paths with trailing slashes; see website/lib/browse/url.ts). DEFINITION +// links (studio, stage) are repo-independent; INSTANCE links (intent, unit, +// feedback) need the repo's origin coords and return null without them. + +import assert from "node:assert/strict" +import { test } from "node:test" + +const links = await import("../src/statusline/links.ts") +const { renderStatusline } = await import("../src/statusline/render.ts") + +const REPO = { host: "github.com", owner: "gigsmart", repo: "haiku-method" } +const B = "https://haikumethod.ai" + +test("studioDefUrl → static studios route", () => { + assert.equal(links.studioDefUrl("software"), `${B}/studios/software/`) + assert.equal(links.studioDefUrl(""), null) +}) + +test("stageDefUrl → stage def within the studio", () => { + assert.equal( + links.stageDefUrl("software", "development"), + `${B}/studios/software/stages/development/`, + ) + assert.equal(links.stageDefUrl("software", ""), null) + assert.equal(links.stageDefUrl("", "development"), null) +}) + +test("intentBrowseUrl → browse SPA path keyed on origin", () => { + assert.equal( + links.intentBrowseUrl(REPO, "my-intent"), + `${B}/browse/github.com/gigsmart/haiku-method/intent/my-intent/`, + ) +}) + +test("unitBrowseUrl uses keyword-delimited stage/unit segments", () => { + assert.equal( + links.unitBrowseUrl(REPO, "my-intent", "development", "unit-03-foo"), + `${B}/browse/github.com/gigsmart/haiku-method/intent/my-intent/stage/development/unit/unit-03-foo/`, + ) +}) + +test("feedbackBrowseUrl — stage-scoped vs intent-scoped", () => { + assert.equal( + links.feedbackBrowseUrl(REPO, "my-intent", "development", "FB-007"), + `${B}/browse/github.com/gigsmart/haiku-method/intent/my-intent/stage/development/feedback/FB-007/`, + ) + assert.equal( + links.feedbackBrowseUrl(REPO, "my-intent", "", "FB-007"), + `${B}/browse/github.com/gigsmart/haiku-method/intent/my-intent/feedback/FB-007/`, + ) +}) + +test("instance links are null with no repo coords (local-only repo)", () => { + assert.equal(links.intentBrowseUrl(null, "my-intent"), null) + assert.equal(links.unitBrowseUrl(null, "my-intent", "dev", "u-01"), null) + assert.equal(links.feedbackBrowseUrl(null, "my-intent", "dev", "FB-1"), null) +}) + +test("GitLab subgroup repo path is preserved", () => { + const gl = { host: "gitlab.com", owner: "group", repo: "sub/proj" } + assert.equal( + links.intentBrowseUrl(gl, "i"), + `${B}/browse/gitlab.com/group/sub/proj/intent/i/`, + ) +}) + +test("HAIKU_WEB_BASE overrides the host (trailing slash stripped)", () => { + const prev = process.env.HAIKU_WEB_BASE + process.env.HAIKU_WEB_BASE = "http://localhost:3000/" + try { + assert.equal( + links.studioDefUrl("software"), + "http://localhost:3000/studios/software/", + ) + assert.equal( + links.intentBrowseUrl(REPO, "i"), + "http://localhost:3000/browse/github.com/gigsmart/haiku-method/intent/i/", + ) + } finally { + if (prev === undefined) delete process.env.HAIKU_WEB_BASE + else process.env.HAIKU_WEB_BASE = prev + } +}) + +// ── renderer OSC 8 wrapping ── + +const OSC8_OPEN = "\x1b]8;;" +const BEL = "\x07" + +function baseState(over = {}) { + return { + intent: "my-intent", + studio: "software", + stages: [], + activeStage: "", + phaseLabel: "execute", + phaseKind: "execute", + gated: false, + aggregate: "", + phaseTrack: null, + ...over, + } +} + +test("renderer wraps intent + studio in OSC 8 when URLs present", () => { + const out = renderStatusline( + baseState({ + intentUrl: `${B}/browse/github.com/o/r/intent/my-intent/`, + studioUrl: `${B}/studios/software/`, + }), + { color: false }, + ) + assert.ok( + out.includes(`${OSC8_OPEN}${B}/studios/software/${BEL}`), + "studio tag should be an OSC 8 link", + ) + assert.ok( + out.includes( + `${OSC8_OPEN}${B}/browse/github.com/o/r/intent/my-intent/${BEL}`, + ), + "intent word should be an OSC 8 link", + ) +}) + +test("renderer emits no OSC 8 when URLs absent (local-only repo)", () => { + const out = renderStatusline(baseState(), { color: false }) + assert.ok(!out.includes(OSC8_OPEN), "no link wrappers without URLs") + assert.ok(out.includes("my-intent")) + assert.ok(out.includes("software")) +}) + +test("stage hexagons + active stage word link to stage defs", () => { + const stageUrl = `${B}/studios/software/stages/development/` + const out = renderStatusline( + baseState({ + stages: [ + { + name: "inception", + status: "done", + url: `${B}/studios/software/stages/inception/`, + }, + { name: "development", status: "active", url: stageUrl }, + ], + activeStage: "development", + }), + { color: false }, + ) + assert.ok( + out.includes(`${OSC8_OPEN}${B}/studios/software/stages/inception/${BEL}`), + "done stage hexagon should link", + ) + const devLinks = out.split(`${OSC8_OPEN}${stageUrl}${BEL}`).length - 1 + assert.ok( + devLinks >= 2, + `active stage hexagon + word should both link (saw ${devLinks})`, + ) +}) + +test("unit/feedback chips link via itemBars url", () => { + const url = `${B}/browse/github.com/o/r/intent/i/stage/dev/unit/unit-03-foo/` + const out = renderStatusline( + baseState({ + itemBars: [{ id: "U-03", segments: ["done", "active"], url }], + }), + { color: false }, + ) + assert.ok( + out.includes(`${OSC8_OPEN}${url}${BEL}`), + "unit chip should be an OSC 8 link", + ) +}) diff --git a/packages/haiku/test/statusline.test.mjs b/packages/haiku/test/statusline.test.mjs index 36e1b4812..83e91152e 100644 --- a/packages/haiku/test/statusline.test.mjs +++ b/packages/haiku/test/statusline.test.mjs @@ -838,8 +838,11 @@ test("resolveStatuslineState: execute phase shows the WHOLE current wave (done + // numeric order: completed (all done), in-flight (active hat), and the // not-yet-started member (empty progress). Nothing is excluded. const pend = (n) => Array(n).fill("pending") + // Compare id + segments only — bars also carry an optional `url` (the + // browse deep link, undefined without a parseable origin) that this + // wave-membership test doesn't assert on. assert.deepEqual( - state.itemBars, + state.itemBars?.map((b) => ({ id: b.id, segments: b.segments })), [ // all hats advanced → all done { id: "U-01", segments: Array(hats.length).fill("done") }, diff --git a/packages/haiku/test/upload-proof.test.mjs b/packages/haiku/test/upload-proof.test.mjs new file mode 100644 index 000000000..f39e07825 --- /dev/null +++ b/packages/haiku/test/upload-proof.test.mjs @@ -0,0 +1,252 @@ +// upload-proof.test.mjs — assert the per-provider REST shape (GitHub release +// asset vs GitLab project uploads) through an injectable fetch, plus the +// no-auth / bad-input error paths. No network, no real provider. +// +// Imports the TS source via tsx (the test runner is `npx tsx`). + +import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" +import { mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { test } from "node:test" + +const SRC = new URL("../src/", import.meta.url).pathname + +/** Build a fake fetch from a queue of response factories; records calls. */ +function scriptedFetch(handlers) { + const calls = [] + const fetchImpl = async (url, init) => { + calls.push({ url: String(url), init: init ?? {} }) + const handler = handlers.shift() + if (!handler) throw new Error(`unexpected fetch to ${url}`) + return handler(String(url), init) + } + return { fetchImpl, calls } +} + +function jsonResponse(status, body) { + return { ok: status >= 200 && status < 300, status, json: async () => body } +} + +const baseCtx = { + provider: "github", + host: "github.com", + owner: "gigsmart", + repo: "haiku-method", + token: "gho_secret", + fileName: "proof.webm", + fileBytes: Buffer.from([1, 2, 3, 4]), +} + +test("uploadProofGitHub: existing release → asset PUT to uploads host", async () => { + const mod = await import( + `${SRC}tools/orchestrator/haiku_upload_proof.ts?d=ghexisting` + ) + const { fetchImpl, calls } = scriptedFetch([ + // GET release by tag → found + () => jsonResponse(200, { id: 42 }), + // POST asset upload + () => + jsonResponse(201, { + browser_download_url: + "https://github.com/gigsmart/haiku-method/releases/download/haiku-proof/proof.webm", + }), + ]) + + const result = await mod.uploadProofGitHub(baseCtx, fetchImpl) + assert.equal(result.provider, "github") + assert.match(result.url, /releases\/download\/haiku-proof\/proof\.webm$/) + assert.equal(result.markdown, null) + + // release lookup hits api.github.com with the tag + bearer + assert.equal( + calls[0].url, + "https://api.github.com/repos/gigsmart/haiku-method/releases/tags/haiku-proof", + ) + assert.equal(calls[0].init.headers.authorization, "Bearer gho_secret") + // asset upload hits uploads.github.com with the release id + ?name= + assert.equal( + calls[1].url, + "https://uploads.github.com/repos/gigsmart/haiku-method/releases/42/assets?name=proof.webm", + ) + assert.equal(calls[1].init.method, "POST") + assert.equal( + calls[1].init.headers["content-type"], + "application/octet-stream", + ) +}) + +test("uploadProofGitHub: missing release (404) → create then upload", async () => { + const mod = await import( + `${SRC}tools/orchestrator/haiku_upload_proof.ts?d=gh404` + ) + const { fetchImpl, calls } = scriptedFetch([ + // GET release by tag → 404 + () => jsonResponse(404, {}), + // POST create release + () => jsonResponse(201, { id: 99 }), + // POST asset upload + () => + jsonResponse(201, { + browser_download_url: + "https://github.com/x/y/releases/download/z/proof.webm", + }), + ]) + + const result = await mod.uploadProofGitHub(baseCtx, fetchImpl) + assert.equal(result.provider, "github") + assert.equal(calls.length, 3) + // release creation POSTs to /releases + assert.equal( + calls[1].url, + "https://api.github.com/repos/gigsmart/haiku-method/releases", + ) + assert.equal(calls[1].init.method, "POST") + // upload references the newly created release id 99 + assert.match(calls[2].url, /releases\/99\/assets\?name=proof\.webm$/) +}) + +test("uploadProofGitHub: asset upload HTTP error → ProofUploadError", async () => { + const mod = await import( + `${SRC}tools/orchestrator/haiku_upload_proof.ts?d=gherr` + ) + const { fetchImpl } = scriptedFetch([ + () => jsonResponse(200, { id: 1 }), + () => jsonResponse(422, {}), + ]) + await assert.rejects( + () => mod.uploadProofGitHub(baseCtx, fetchImpl), + (err) => { + assert.ok(err instanceof mod.ProofUploadError) + assert.equal(err.code, "proof_upload_github_asset_failed") + return true + }, + ) +}) + +test("uploadProofGitLab: POSTs to project uploads API with Authorization: Bearer", async () => { + const mod = await import( + `${SRC}tools/orchestrator/haiku_upload_proof.ts?d=gl` + ) + const { fetchImpl, calls } = scriptedFetch([ + () => + jsonResponse(201, { + url: "/uploads/abc123/proof.webm", + markdown: "[proof.webm](/uploads/abc123/proof.webm)", + full_path: "/-/project/uploads/abc123/proof.webm", + }), + ]) + + const ctx = { + ...baseCtx, + provider: "gitlab", + host: "gitlab.com", + token: "oauth_token", + } + const result = await mod.uploadProofGitLab(ctx, fetchImpl) + + assert.equal(result.provider, "gitlab") + assert.ok(result.markdown?.includes("proof.webm")) + // endpoint shape: /api/v4/projects//uploads + assert.equal( + calls[0].url, + "https://gitlab.com/api/v4/projects/gigsmart%2Fhaiku-method/uploads", + ) + assert.equal(calls[0].init.method, "POST") + // OAuth tokens (broker-issued) require Authorization: Bearer, NOT the + // PAT-only PRIVATE-TOKEN header (which 401s an OAuth token). + assert.equal(calls[0].init.headers.authorization, "Bearer oauth_token") +}) + +test("uploadProofGitLab: HTTP error → ProofUploadError", async () => { + const mod = await import( + `${SRC}tools/orchestrator/haiku_upload_proof.ts?d=glerr` + ) + const { fetchImpl } = scriptedFetch([() => jsonResponse(403, {})]) + const ctx = { ...baseCtx, provider: "gitlab", host: "gitlab.com" } + await assert.rejects( + () => mod.uploadProofGitLab(ctx, fetchImpl), + (err) => { + assert.ok(err instanceof mod.ProofUploadError) + assert.equal(err.code, "proof_upload_gitlab_failed") + return true + }, + ) +}) + +test("haiku_upload_proof: missing path → proof_upload_path_missing", async () => { + const tool = (await import(`${SRC}tools/orchestrator/haiku_upload_proof.ts`)) + .default + const res = await tool.handle({ + intent: "demo", + path: "/nonexistent/does/not/exist.webm", + }) + const body = JSON.parse(res.content[0].text) + assert.equal(body.error, "proof_upload_path_missing") +}) + +test("haiku_upload_proof: bad input rejected by gate", async () => { + const tool = (await import(`${SRC}tools/orchestrator/haiku_upload_proof.ts`)) + .default + // missing required `path` + const res = await tool.handle({ intent: "demo" }) + assert.equal(res.isError, true) + assert.match(res.content[0].text, /haiku_upload_proof_input_invalid/) +}) + +test("haiku_upload_proof: no token → AUTHENTICATES (doesn't ask) and surfaces auth_unavailable when the broker can't be reached", async () => { + const HAS_GIT = (() => { + try { + execFileSync("git", ["--version"], { stdio: "ignore" }) + return true + } catch { + return false + } + })() + if (!HAS_GIT) return + + const repo = mkdtempSync(join(tmpdir(), "haiku-upload-auth-")) + const globalDir = mkdtempSync(join(tmpdir(), "haiku-upload-global-")) + const origCwd = process.cwd() + const origGlobal = process.env.HAIKU_GLOBAL_DIR + const origProxy = process.env.HAIKU_AUTH_PROXY_URL + try { + execFileSync("git", ["init", "-q"], { cwd: repo }) + // A GitHub origin so the provider resolves; no token in the empty global + // store; the broker pointed at a dead local port (fast ECONNREFUSED, so + // /cli/start fails before any browser open). + execFileSync( + "git", + ["remote", "add", "origin", "https://github.com/acme/widgets.git"], + { cwd: repo }, + ) + const proof = join(repo, "proof.webm") + writeFileSync(proof, "fake video bytes") + process.chdir(repo) + process.env.HAIKU_GLOBAL_DIR = globalDir // empty → no stored token + process.env.HAIKU_AUTH_PROXY_URL = "http://127.0.0.1:1" + + const tool = ( + await import(`${SRC}tools/orchestrator/haiku_upload_proof.ts?d=authpath`) + ).default + const res = await tool.handle({ intent: "demo", path: proof }) + const body = JSON.parse(res.content[0].text) + // The engine auto-authed (hit the broker) and, since it couldn't, surfaced + // the new error — NOT the old "run haiku_auth_login first." + assert.equal(body.error, "proof_upload_auth_unavailable") + assert.doesNotMatch( + res.content[0].text, + /haiku_auth_login|proof_upload_no_auth/, + "must not tell the agent to authenticate first", + ) + } finally { + process.chdir(origCwd) + if (origGlobal === undefined) delete process.env.HAIKU_GLOBAL_DIR + else process.env.HAIKU_GLOBAL_DIR = origGlobal + if (origProxy === undefined) delete process.env.HAIKU_AUTH_PROXY_URL + else process.env.HAIKU_AUTH_PROXY_URL = origProxy + rmSync(repo, { recursive: true, force: true }) + rmSync(globalDir, { recursive: true, force: true }) + } +}) diff --git a/packages/haiku/test/write-brief-path.test.mjs b/packages/haiku/test/write-brief-path.test.mjs index d53ebd09d..f3c669fbe 100644 --- a/packages/haiku/test/write-brief-path.test.mjs +++ b/packages/haiku/test/write-brief-path.test.mjs @@ -1,20 +1,18 @@ #!/usr/bin/env npx tsx // write-brief-path.test.mjs // -// Regression for the 2026-05-26 bug: the briefer subagent wrote BRIEF.md -// to the GLOBAL haiku dir (~/.haiku/projects/…, where the prompt file -// itself lives in dev mode) instead of the repo's stage dir. Root cause: -// the prompt said "Write BRIEF.md at the stage root — stages//BRIEF.md", -// dropping the `.haiku/intents//` prefix. The agent writes it with -// the generic Write tool, so the under-specified relative path resolved -// outside the repo. +// The 2026-05-26 bug class: the briefer subagent wrote BRIEF.md with the +// generic Write tool, and an under-specified relative path resolved outside +// the repo (into the GLOBAL ~/.haiku/projects/… dir where the prompt file +// lives in dev mode), so the engine never saw it and the cursor re-emitted +// write_brief forever. // -// The engine ONLY reads BRIEF.md from `.haiku/intents//stages// -// BRIEF.md` (cursor `stillOwesBrief`, session-api, and the guard regex), so -// a brief written anywhere else is invisible and the cursor re-emits -// write_brief forever. This test pins that the subagent prompt names the -// full repo-relative path and warns against the metadata dir — matching the -// fix already in record_observations. +// The 2026-05-29 redesign eliminates that class structurally: the briefer +// calls the `haiku_write_brief { body }` tool and the ENGINE writes BRIEF.md +// to the canonical `.haiku/intents//stages//BRIEF.md` path it +// reads from — the agent never names a path, so it can never get it wrong. +// This test now pins that NEW contract: the prompt routes through the tool +// (body only) and does NOT instruct a direct file write. import assert from "node:assert/strict" import { mkdtempSync, readFileSync, rmSync } from "node:fs" @@ -45,36 +43,35 @@ function briefSubagentBody(slug = "demo-intent", stage = "design") { ) } -test("briefer prompt names the FULL repo-relative BRIEF.md path", async () => { +test("briefer prompt routes through haiku_write_brief with body only", async () => { const body = await briefSubagentBody("demo-intent", "design") assert.match( body, - /\.haiku\/intents\/demo-intent\/stages\/design\/BRIEF\.md/, - "must name the full `.haiku/intents//stages//BRIEF.md` path the engine reads", + /haiku_write_brief\s*\{\s*body:/, + "must instruct calling `haiku_write_brief { body: … }`", ) }) -test("briefer prompt does NOT use the prefix-less `stages//BRIEF.md` (the bug)", async () => { +test("briefer prompt does NOT hand the agent a BRIEF.md filesystem path", async () => { const body = await briefSubagentBody("demo-intent", "design") - // The truncated form must not appear except as the tail of the full - // path. Strip the full paths, then assert no bare occurrence remains. - const withoutFull = body.replace( - /\.haiku\/intents\/[^/]+\/stages\/[^/]+\/BRIEF\.md/g, - "", - ) + // The engine owns the path now — the prompt must not name any BRIEF.md + // filesystem path for the agent to write to. (Telling the agent NOT to use + // the Write tool is fine and expected; we only forbid a positive path.) assert.doesNotMatch( - withoutFull, - /(^|[^/])stages\/[^/]*\/BRIEF\.md/, - "must not tell the agent to write a prefix-less stages//BRIEF.md", + body, + /stages\/[^/]*\/BRIEF\.md|\.haiku\/intents\/[^/]*\/.*BRIEF\.md/, + "prompt must not name a BRIEF.md path — that's the tool's job", ) }) -test("briefer prompt warns against the global ~/.haiku metadata dir", async () => { +test("briefer prompt does NOT make the agent specify intent/stage/phase", async () => { const body = await briefSubagentBody("demo-intent", "design") - assert.match( + // Those are engine-resolved (intent from branch, stage from cursor, phase + // from file existence). The tool call the prompt shows must be body-only. + assert.doesNotMatch( body, - /~\/\.haiku\/projects|metadata dir|repo-relative/i, - "must warn the brief is repo-relative, not the ~/.haiku metadata dir", + /haiku_write_brief\s*\{[^}]*\b(intent|stage|phase)\s*:/, + "the haiku_write_brief call must pass body only — no intent/stage/phase", ) }) diff --git a/plugin/bin/haiku b/plugin/bin/haiku index 1557fb6c7..8aad69365 100755 --- a/plugin/bin/haiku +++ b/plugin/bin/haiku @@ -22,7 +22,26 @@ # bundle behavior in a dev checkout). HAIKU_DEV=1 forces source mode # (custom plugin install paths that should still hot-reload). set -euo pipefail -HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Canonicalize this script's path through any symlinks BEFORE computing HERE. +# An npx install (`npx -y haiku-method statusline`) is invoked through +# `node_modules/.bin/haiku-method`, which npm creates as a symlink to +# `../haiku-method/bin/haiku`. Without resolving it, `dirname "$BASH_SOURCE"` +# is the `.bin` dir, so the bundle (`$HERE/haiku.mjs`) and source +# (`$HERE/../../packages/...`) both resolve to non-existent paths and the +# script dies with "cannot locate bundle". Resolving the symlink puts HERE at +# the REAL `/bin` dir where `haiku.mjs` lives. A no-op for dev / market- +# place installs (there `bin/haiku` is a plain file, not a symlink), so it's +# safe everywhere. macOS has no `readlink -f`, so walk the chain by hand. +SOURCE="${BASH_SOURCE[0]}" +while [ -L "$SOURCE" ]; do + DIR="$(cd "$(dirname "$SOURCE")" && pwd)" + SOURCE="$(readlink "$SOURCE")" + case "$SOURCE" in + /*) : ;; + *) SOURCE="$DIR/$SOURCE" ;; + esac +done +HERE="$(cd "$(dirname "$SOURCE")" && pwd)" BUNDLE="$HERE/haiku.mjs" SOURCE_ENTRY="$HERE/../../packages/haiku/src/main.ts" diff --git a/plugin/prompts/_shared/runtime-verification.md b/plugin/prompts/_shared/runtime-verification.md index 725165445..f640a15b3 100644 --- a/plugin/prompts/_shared/runtime-verification.md +++ b/plugin/prompts/_shared/runtime-verification.md @@ -62,9 +62,9 @@ Video of the run, step screenshots, response bodies, pane dumps, computed-style **Proof is gitignored — upload it to the PR so it survives.** The `proof/` dir is gitignored on purpose: video and screenshots are regenerated every run, and committing that binary churn bloats history forever. So the captures do NOT travel when a branch merges. To make them durable and reviewable, **upload them to the change request** for this scope (your dispatch tells you the target PR/MR URL when you have one): -- **GitLab** has a first-class upload: `glab` (or `POST /projects/:id/uploads`) returns a markdown snippet — embed it in the MR description/note under a "Proof" section. -- **GitHub** has no inline-attachment API. Attach the video/screenshots as **release assets** (`gh release upload`, draft/tag release) or push them to an artifact bucket, then link them from the PR body's "Proof" section. (Inline image paste only works in the web UI, which you can't drive.) -- Keep it **idempotent**: re-running replaces the PR's "Proof" section rather than stacking duplicates. If you have no PR URL (no provider CLI / not a git repo), skip the upload — the captures still sit on disk and the SPA serves them live over the tunnel. +- **Preferred: `haiku_upload_proof { intent, stage, path }`** — call it once per capture file (the `.webm`, each step screenshot). It detects the repo's provider, **authenticates automatically** when needed (the haikumethod.ai broker — you NEVER pre-call an auth tool), and posts the file to the change request: a GitHub **release asset**, or the GitLab **project-uploads** API (which returns a markdown ref). It returns the durable URL; link the URLs from the PR/MR body's "Proof" section. +- It returns `proof_upload_auth_unavailable` only when auth genuinely can't be obtained (broker unreachable, declined, or no provider remote). In that case fall back to the CLI: **GitLab** `glab` / `POST /projects/:id/uploads`; **GitHub** `gh release upload` (draft/tag release) — then link from the PR body. (Inline image paste is web-UI-only; you can't drive it.) +- Keep it **idempotent**: re-running replaces the PR's "Proof" section rather than stacking duplicates. If you have no PR URL / not a git repo, skip the upload — the captures still sit on disk and the SPA serves them live over the tunnel. Attach the same captures (or their uploaded links) to any feedback you file. diff --git a/plugin/prompts/stage/review/write_brief/subagent.eta.md b/plugin/prompts/stage/review/write_brief/subagent.eta.md index 2c611d982..caf951175 100644 --- a/plugin/prompts/stage/review/write_brief/subagent.eta.md +++ b/plugin/prompts/stage/review/write_brief/subagent.eta.md @@ -1,3 +1,40 @@ +<% if (phase === "post") { %> +# Briefer — rewrite the user-facing brief for stage `<%= stage %>` (what was built) + +You are the **briefer** for stage `<%= stage %>` of intent `<%= slug %>`. The stage has finished building: every unit is approved and the quality gates have run. Your one job: rewrite `BRIEF.md` — the same brief that, before the work started, said "this is what I am going to do" — so it now says "this is what I did", for the **human** who wants an honest summary of what actually shipped. + +## Who you're writing for + +A stakeholder who wants to know what landed — NOT an engineer on this work. They have not read the units, the outputs, or any code. Lead with what got built and what it means for them. Keep it scannable. No internal jargon, no file paths, no tool names, no workflow mechanics — if a sentence only makes sense to someone inside the codebase, rewrite it. + +## What to read + +Read what the stage actually produced, so the brief reflects reality, not the plan: + +- The intent — `haiku_read_intent { intent: "<%= slug %>" }`. +- Every unit and what it produced — `haiku_unit_list { intent: "<%= slug %>", stage: "<%= stage %>" }`, then `haiku_unit_read` each one and `haiku_read_output` for its outputs. +- Any feedback that was raised and closed during the stage — `haiku_feedback_list { intent: "<%= slug %>" }` — so the brief is honest about what changed along the way. +- The existing `BRIEF.md` (the pre-execute plan) so you know what was promised and can note where the result diverged. + +## What to write + +Write the brief by calling `haiku_write_brief { body: "" }`. Pass ONLY the prose body — no frontmatter, no `---` block, no intent, no stage, no file path. The engine resolves the intent + stage from where you are and stamps the phase itself (it sees the brief already exists from the pre-execute write, so it marks this one as the closing brief). Don't use the Write tool and don't touch the file directly. + +Shape the body for a human skim, in plain prose with light headings: + +- **What this stage delivered** — one or two sentences a non-engineer understands, in the past tense. +- **Why it matters** — the value it added or the problem it solved for the user. +- **What changed from the plan** — anything that diverged from the pre-execute brief, and why. If nothing diverged, say so briefly. +- **Worth a human eye** — anything carried forward, deferred, or still worth a second look. + +Keep it tight. It's a brief, not a report — favor a page the reviewer actually reads over an exhaustive one they skim past. + +## Rules + +- The brief is USER-FACING. No other agent will ever read it — write for the human, not for the workflow. +- You only call `haiku_write_brief`. Do not modify any unit, the intent, feedback, or any code. +- When done, your final message is one line: `rewrote BRIEF.md for <%= stage %> (post-execute)`. +<% } else { %> # Briefer — write the user-facing brief for stage `<%= stage %>` You are the **briefer** for stage `<%= stage %>` of intent `<%= slug %>`. Your one job: write `BRIEF.md` — a plain-language summary of the work this stage is about to do, for the **human** who reviews the plan at the gate. It's the first thing they see. @@ -17,11 +54,9 @@ This is the one place where a wide read is the job — gather everything that ex ## What to write -Write `BRIEF.md` with the Write tool at the stage root — `.haiku/intents/<%= slug %>/stages/<%= stage %>/BRIEF.md`. - -> **Path is repo-relative — the project working tree, NOT the engine metadata dir.** Write under your repo root, into the same `.haiku/intents/<%= slug %>/` tree that already holds `units/` and `feedback/`. Do **NOT** write it into the `~/.haiku/projects/…` directory where this prompt file lives (that's engine bookkeeping; the engine reads `BRIEF.md` only from the repo tree, so a file written to the metadata dir is invisible — the cursor will re-emit `write_brief` and make no progress). +Write the brief by calling `haiku_write_brief { body: "" }`. Pass ONLY the prose body — no frontmatter, no `---` block, no intent, no stage, no file path. The engine resolves the intent + stage from where you are and stamps the phase itself (this is the first brief, so it marks it as the plan). Don't use the Write tool and don't touch the file directly. -Shape it for a human skim, in plain prose with light headings: +Shape the body for a human skim, in plain prose with light headings: - **What this stage delivers** — one or two sentences a non-engineer understands. - **Why it matters** — the value or the problem it solves for the user. @@ -34,5 +69,6 @@ Keep it tight. It's a brief, not a report — favor a page the reviewer actually ## Rules - The brief is USER-FACING. No other agent will ever read it — write for the human, not for the workflow. -- You only WRITE `BRIEF.md`. Do not modify any unit, the intent, feedback, or any code. +- You only call `haiku_write_brief`. Do not modify any unit, the intent, feedback, or any code. - When done, your final message is one line: `wrote BRIEF.md for <%= stage %>`. +<% } %> diff --git a/plugin/prompts/stage/review/write_brief/template.eta.md b/plugin/prompts/stage/review/write_brief/template.eta.md index f90716862..3dbd682e1 100644 --- a/plugin/prompts/stage/review/write_brief/template.eta.md +++ b/plugin/prompts/stage/review/write_brief/template.eta.md @@ -1,3 +1,16 @@ +<% if (phase === "post") { %> +# Rewrite the stage brief for `<%= stage %>` (post-execute, before the stage closes) + +Stage `<%= stage %>` has finished building: every unit is approved and the quality gates have run. Before the stage closes, one briefer subagent rewrites the user-facing `BRIEF.md` — flipping it from "this is what I am going to do" to "this is what I did" so the human sees an honest summary of the work that actually landed. + +## What to do + +Spawn the briefer subagent below (single Task call). Its full prompt — what to read, what to write, who it's for — is in the file referenced by the `` block; pass that block verbatim to the Task tool. + +<%~ dispatchBlock %> + +When the briefer terminates, call `haiku_run_next { intent: "<%= slug %>" }`. The cursor closes the stage once the closing brief is finalized. +<% } else { %> # Write the stage brief for `<%= stage %>` (pre-execute, before the user gate) The spec for stage `<%= stage %>` has passed adversarial review. Before the user gate opens, one briefer subagent writes the user-facing `BRIEF.md` — a plain-language summary of what this stage is about to build, written for the human who's about to review the plan. @@ -9,3 +22,4 @@ Spawn the briefer subagent below (single Task call). Its full prompt — what to <%~ dispatchBlock %> When the briefer terminates, call `haiku_run_next { intent: "<%= slug %>" }`. The cursor routes to the review user gate once `BRIEF.md` exists (or straight to execution when the stage has no gate). +<% } %> diff --git a/plugin/providers/git.md b/plugin/providers/git.md index f8f91081e..3414162f5 100644 --- a/plugin/providers/git.md +++ b/plugin/providers/git.md @@ -35,9 +35,11 @@ The engine also opens a per-stage draft PR at stage start (base = `haiku// ## Proof asset uploads (runtime-verification evidence) -Runtime-verification proof (screenshots, video) is **gitignored** — it's regenerated every run and committing it bloats history. It does not travel on a branch merge, so a runtime-verifier uploads it to the relevant PR/MR to make it durable and reviewable. The two hosts differ: +Runtime-verification proof (screenshots, video) is **gitignored** — it's regenerated every run and committing it bloats history. It does not travel on a branch merge, so a runtime-verifier uploads it to the relevant PR/MR to make it durable and reviewable. -- **GitLab** — first-class: `glab` / `POST /projects/:id/uploads` returns a markdown snippet to embed in the MR description or a note. Access-controlled by project visibility. +The engine-preferred path is the **`haiku_upload_proof`** tool: it detects the repo's provider, **authenticates when needed** through the haikumethod.ai OAuth broker (no pre-call to an auth tool), and posts the file to the change request over the provider REST API — GitHub **release asset**, GitLab **project-uploads** (returns a markdown ref). It returns the durable URL. The CLI is the fallback when the tool reports `proof_upload_auth_unavailable` (broker unreachable / no provider): + +- **GitLab** — `glab` / `POST /projects/:id/uploads` returns a markdown snippet to embed in the MR description or a note. Access-controlled by project visibility. - **GitHub** — no inline-attachment API. Attach captures as **release assets** (`gh release upload`) or push to an artifact bucket, then link them from the PR body. (Inline image paste is web-UI-only; a bot can't drive it. Note: GitHub's `user-attachments` CDN URLs are anonymized — anyone with the link can view, even for a private repo.) Keep uploads idempotent — replace the PR's "Proof" section on re-run rather than stacking duplicates. diff --git a/website/app/browse/components/IntentDetailView.tsx b/website/app/browse/components/IntentDetailView.tsx index 26f549330..d9b8162f9 100644 --- a/website/app/browse/components/IntentDetailView.tsx +++ b/website/app/browse/components/IntentDetailView.tsx @@ -75,6 +75,11 @@ interface Props { provider: BrowseProvider location?: BrowseLocation initialStage?: string + /** Feedback finding id to deep-link to (from a feedback browse URL). On + * mount the matching card is scrolled into view and briefly ring- + * highlighted. Its stage scope (when any) is expanded so the card is + * rendered. */ + initialFeedback?: string onBack: () => void } @@ -83,6 +88,7 @@ export function IntentDetailView({ provider, location, initialStage, + initialFeedback, onBack, }: Props) { const router = useRouter() @@ -94,8 +100,13 @@ export function IntentDetailView({ // jumping straight into the active stage — only expand a stage when one is // explicitly deep-linked. The active stage is one click away and carries // the amber dot in the pipeline. + // A feedback deep link both lands on a stage (its scope, so the card is + // rendered) and the explicit `initialStage`. `feedbackStage` is the scope + // the URL named (empty for an intent-scoped FB → the always-rendered intent + // Feedback section carries it). + const feedbackStage = initialFeedback ? location?.stage : undefined const [expandedStage, setExpandedStage] = useState( - initialStage || null, + initialStage || feedbackStage || null, ) const stageRefs = useRef>({}) const [viewMode, setViewMode] = useState<"pipeline" | "board">("pipeline") @@ -142,7 +153,7 @@ export function IntentDetailView({ // Scroll to initially expanded stage on mount useEffect(() => { const target = initialStage || location?.stage - if (target && !location?.unit) { + if (target && !location?.unit && !initialFeedback) { // Small delay so DOM has rendered the expanded stage section const timeout = setTimeout(() => { stageRefs.current[target]?.scrollIntoView({ @@ -152,7 +163,37 @@ export function IntentDetailView({ }, 150) return () => clearTimeout(timeout) } - }, [initialStage, location?.stage, location?.unit]) + }, [initialStage, location?.stage, location?.unit, initialFeedback]) + + // Deep-link to a specific feedback finding: scroll its card into view and + // briefly ring-highlight it. The card carries a stable DOM id (`fb-`); + // its stage scope was expanded above so it's mounted by now. The ring is a + // transient class toggle (no React state on the card) — added on arrival, + // removed after the pulse so it doesn't persist on later interaction. + useEffect(() => { + if (!initialFeedback) return + const timeout = setTimeout(() => { + const el = document.getElementById(`fb-${initialFeedback}`) + if (!el) return + el.scrollIntoView({ behavior: "smooth", block: "center" }) + el.classList.add( + "ring-2", + "ring-teal-400", + "ring-offset-2", + "dark:ring-offset-stone-900", + ) + const clear = setTimeout(() => { + el.classList.remove( + "ring-2", + "ring-teal-400", + "ring-offset-2", + "dark:ring-offset-stone-900", + ) + }, 2400) + el.dataset.fbHighlightTimer = String(clear) + }, 200) + return () => clearTimeout(timeout) + }, [initialFeedback]) // Listen for browser back/forward (path-based navigation only) useEffect(() => { @@ -2480,6 +2521,9 @@ function FeedbackCard({ scope: "stage" | "intent" }) { const [open, setOpen] = useState(false) + // Stable anchor for feedback deep links — the IntentDetailView mount effect + // scrolls + ring-highlights `#fb-` when a feedback browse URL names it. + const anchorId = `fb-${fb.id}` const isHuman = fb.authorType === "human" const isClosed = fb.closedAt != null const pillClass = isHuman @@ -2495,7 +2539,8 @@ function FeedbackCard({ const severityBadge = fb.severity ? SEVERITY_BADGES[fb.severity] : null return (
) diff --git a/website/app/studios/[slug]/architecture/_data/actors.ts b/website/app/studios/[slug]/architecture/_data/actors.ts index 6d9df6d26..4c0b637e0 100644 --- a/website/app/studios/[slug]/architecture/_data/actors.ts +++ b/website/app/studios/[slug]/architecture/_data/actors.ts @@ -81,7 +81,7 @@ export const ACTORS: Record = { "v0→v4 migrator, run once on first tick of any pre-v4 intent", ], notes: - "**The cursor model — v4's reconciliation point.** `derivePosition(slug)` reads disk and walks three tracks in priority order:\n\n1. **Track C — drift sweep.** Re-hashes each unit's body / declared outputs and compares against the FM witness (`reviews..body_sha256`, `approvals..witnesses[]`). Discovery is NOT a witnessed surface — its signal is artifact existence at the studio template's `location:`, so there's no stamp to drift against. Mismatch → `drift_detected { events }`. Dedup'd against open drift FBs by `source_ref` so a fired FB suppresses re-emission until it closes. Pre-v4 baseline artifacts (`baseline.json`, `drift-markers.json`, `baseline-content/`) are deleted by the v0→v4 migrator.\n\n2. **Track B — feedback.** Walks every stage from index 0 through the active stage, then intent-scope. Open FB → `start_feedback_hat` (next fix-hat dispatch) or `close_feedback` (terminal advance landed). Cross-stage routing is purely by file location: an FB in `stages//feedback/` rewinds the cursor to that stage's fix loop on the next tick. There is no `upstream_stage:` field and no pre-tick triage gate — classification is the first hat in the stage's `fix_hats:` chain (calls `haiku_feedback_set_targets`).\n\n3. **Track A — intent.** Pre-stage walk fires `elaborate_loop` (no `stage` field, `signals_unmet: [{signal: \"verify_conversation\"}]`) when `intent.md` lacks `verified_at` on a fresh non-autopilot intent (grandfathered when stage work has already shipped). On the active stage (first stage whose branch is not merged into intent main), the cursor walks the per-stage state machine in lifecycle order: `elaborate_loop` (single state carrying every unmet completion signal — `conversation` / `verify_conversation` / `discovery` / `decompose` / `verify_decompose`) → **`dispatch_review` / `user_gate { gate_kind: \"spec\" }` (PRE-execute — audits the SPEC before any code lands)** → `start_unit_hat` (wave logic; while `decompose_verified_at` is absent the loop keeps emitting `verify_decompose` and blocks wave dispatch) → `dispatch_quality_gates` → **`dispatch_approval` / `user_gate { gate_kind: \"approval\" }` (POST-execute — audits the WORK against the already-approved spec)** → `complete_stage`. The pre/post split landed 2026-05-17: `dispatch_review` moved from post-execute to pre-execute; `dispatch_approval` stayed post-execute; engine-built roles fire in BOTH walks with phase-appropriate mandate bodies.\n\n**Elaborate loop — single state, multi-signal payload (GAPS § 1a → Option A, 2026-05-14).** The cursor emits ONE `elaborate_loop` action per tick whose `signals_unmet[]` enumerates every currently-unmet completion signal. The agent may make progress on any subset in the same response — the loop is concurrent at the action shape, not just at the prompt level. The cursor recomputes on the next tick and either returns the still-unmet subset or falls through past the loop. Signals: `conversation` (elaboration.md missing on a fresh stage) / `verify_conversation` (recorded but unverified — carries the verifier nonce at `verifier_nonces.verify_conversation`) / `discovery` (one entry per missing discovery template, carries `agent` + representative `units`) / `decompose` (units.length === 0) / `verify_decompose` (units exist + `decompose_verified_at` missing — carries `verifier_nonces.verify_decompose`). Discovery subagents file `origin: discovery, resolution: question` FBs when surfacing user-decisions; the next tick routes these as `feedback_question` via Track B before the loop continues.\n\n**Verifier nonces (GAPS § 3, 2026-05-14).** The seal tools (`haiku_intent_seal`, `haiku_stage_elaboration_seal`, `haiku_stage_decompose_seal`) require the per-signal `nonce` from `verifier_nonces.`. Nonces are minted by the cursor's wire layer when the matching signal is emitted, persisted at `.haiku/intents//.verifier-nonces.json`, and consumed (deleted) on a successful seal. A confused or main-agent caller that invokes a seal tool without the dispatched verifier's nonce gets `verifier_nonce_invalid` back. Re-recording the elaboration artifact clears the stage-scoped nonces so a stale verifier dispatched against the old body can't seal the new one.\n\nAfter every stage merges, the cursor walks intent-scope approvals (`spec`, `continuity`, `cross-stage-consistency`, studio intent-completion review agents from `intent-review-agents/`, `user`) and emits `intent_review` per missing role. Once every approval is signed and reflection is done, the cursor checks delivery (`intent-delivery.ts`): if `haiku//main` has NOT landed on the repo's default branch it emits `pending_seal` and HOLDS — `sealed_at` stays unwritten and the engine never merges (honors \"never merge unless asked\"). On `/haiku:haiku-pickup` the delivery PR-interaction approval (`delivery-verifier`) is re-opened so it re-audits the open change request and files feedback for new review comments. Once the hub branch is an ancestor of the default branch (merged locally or via a merged PR/MR) the cursor emits `seal_intent`, then `sealed`. Filesystem mode / no resolvable default branch → the gate is inapplicable and it seals straight away.\n\n**Pre-cursor selection gates** — `run-tick.ts` (between migrator and `derivePosition`) emits `select_studio` / `select_mode` / `select_stage` when `intent.studio` / `intent.mode` is unset, or when mode is `quick` and `intent.stages[]` is empty. `haiku_run_next` blocks on the picker UI inline; the agent never sees a \"call haiku_select_*\" instruction.\n\n**Pre-cursor worktree gates (git-layer; the cursor reads none of it, Rule 1).** Also in `run-tick.ts` before `derivePosition`: `resetLostUnits` clears a unit's iterations when its isolation worktree AND branch are gone everywhere (cross-machine pickup with no recoverable ref — gated on `hasGitRemote()`), so the cursor re-dispatches the first hat into a fresh worktree. `completePendingFixChainMerges` re-attempts a fix-chain's terminal merge (via `mergeFixChainWorktree`'s `MERGE_HEAD` re-entry) for advance-closed chains whose worktree survived a conflict, completing it before a stage can advance over stranded code — or returning `integrate_fix_chains` while conflicts remain. Both no-op in filesystem mode and for the common in-flight / clean-merge paths.\n\n**No state.json.** v4 derives stage position from FM. The cursor is straight TypeScript, deterministic given the same disk state, with no LLM in the workflow-position decision. The agent does not hold workflow state in their context — anything they think they remember about waves or hats is incidental; the next tick tells them what's actually next.\n\n**Engine-built review roles (fire in BOTH walks, 2026-05-17):** `spec` (cross-unit acceptance criteria coverage, scope creep, cross-unit drift), `continuity` (handoff fidelity between hats / waves / stages), `cross-stage-consistency` (alignment with what earlier stages produced). No per-studio mandate file for any of these — `dispatch_review` renders pre-execute prose from `prompts/stage/review/dispatch_review/engine-bodies/.eta.md`, and `dispatch_approval` renders post-execute prose from `prompts/stage/approve/dispatch_approval/engine-bodies/.eta.md`.\n\n**Studio review-agent / hat / fix-hat cascade (three-tier, 2026-05-17):** the resolver walks `project/.haiku/studios//stages//review-agents/.md` → `plugin/studios//stages//review-agents/.md` → `plugin/studios//review-agents/.md` → `plugin/review-agents/.md`. First hit wins. Same cascade for `hats/` and `fix-hats/`. Studio-level intent-completion review agents now live at `plugin/studios//intent-review-agents/` (renamed from `review-agents/` to free the studio tier for stage-scope agents).\n\n**Mode shaping (read from `intent.mode`):**\n• `continuous` — full role lists `[spec, continuity, cross-stage-consistency, , user]` (reviews) and `[spec, continuity, cross-stage-consistency, quality_gates, , user]` (approvals). Runtime-observation roles (`RUNTIME_OBSERVATION_ROLES` in `orchestrator/review-role-classes.ts` — currently `runtime-verifier`) are EXCLUDED from the PRE-execute reviews list (`stageRoleLists` filters them — there's no built work to drive before execution); they fire only in approvals + intent-completion.\n• `discrete` — same role lists; a DRAFT stage PR is opened at stage START (`workflowStartStage` → `openStageDraftPullRequest`, branch `haiku//` → base `haiku//main`, stamped in the `stage_prs` map) so proof + work land on it, and the `user` gate flips it draft→ready (`markPullRequestReady`, NOT a second PR) — merge into intent main is the approval signal.\n• `discrete-hybrid` — discrete up to a chosen pivot stage, then continuous; per-stage gate type drives the dispatch choice. Per-stage draft PRs open ONLY for stages whose `review:` gate is/includes `external` (`stageRequiresExternalReview`); the continuous stages keep work on the intent-main PR.\n• `autopilot` — trimmed: reviews `[spec, continuity, cross-stage-consistency]`, approvals `[spec, continuity, cross-stage-consistency, quality_gates]`, no user gate, no studio agents, `complete_stage` auto-fires once the post-execute walk completes.\n• `quick` — single-stage intent (`intent.stages[]` length 1 after `select_stage`).\n\n**Stage→main merge serialization.** Every `complete_stage` runs under `withIntentMainLock` so concurrent stages can't race the merge into intent main. Stages are NEVER sealed — only intents are; a previously-merged stage that gains a new unit (via fix-loop corrective work) becomes ahead-of-main and `firstUnmergedStage` rewinds the cursor to it automatically.\n\n**MCP tool surface** lives in `packages/haiku/src/orchestrator.ts`, `state-tools.ts`, and `server.ts` — including `haiku_run_next` (the tick + blocking shell for every interactive UI), the unit/feedback CRUDL family with TypeBox + AJV input gates, `haiku_intent_*`, `haiku_select_*` (resume entry points; canonical path is engine-side blocking via run_next), `haiku_await_gate`, `haiku_record_agent_write`, `haiku_review_stamp` (stage review/approval subagent closure — stamps `reviews.`/`approvals.` without a cursor walk, so parallel review siblings don't trip the loop guard), `haiku_feedback_advance_hat` / `_reject_hat` / `_set_targets` / `_set_severity` / `_move`, `haiku_unit_advance_hat` / `_reject_hat`, `haiku_settings_get/set`, `haiku_studio_*`, `haiku_capacity`, `haiku_repair`. Numeric `feedback_id` at the wire (display label `FB-001`); the parser accepts both 2-digit and 3-digit filename forms.\n\n**Runtime-verifier surface (2026-05-18).** Bundled `@playwright/mcp@latest --headless` ships in `plugin/.mcp.json` alongside two new state tools: `haiku_view` opens a view session and returns a URL the agent hands to Playwright (boot mode auto-detects the project's `npm run dev` / `bun run dev` / equivalent and spawns it on an ephemeral port; viewer mode tunnels to the SPA's `/view/?artifact=&stage=` artifact-browser route with mime dispatch covering markdown / image / PDF / SVG / source highlight / HTML and specialized web-component viewers for KiCanvas (`.kicad_sch` / `.kicad_pcb`), Tracespace (`.gbr` / `.drl`), `` (`.glb` / `.gltf`), and tscircuit (circuit `.tsx`)). `haiku_view_close` shuts the session + kills any spawned dev server. Lifecycle is layered: explicit close → 30min TTL → tick-scoped orphan sweep via `killAllOrphanedBootSessions` in `run-tick.ts` → process-exit SIGTERM. Runtime-verifier review-agents (`runtime-verifier.md` mandates at stage-tier `review-agents/` and studio-tier `intent-review-agents/`) call `haiku_view` for the boot URL, then drive it with a SELF-INSTALLED Playwright script (own scratch dir, never the project's deps; the Playwright MCP is the fallback) that RECORDS VIDEO + step screenshots into `.haiku/intents//[stages//]proof/`. That `proof/` tree is GITIGNORED (regenerated binary churn — `ensureHaikuGitignored` seeds the globs). `runtime-verifier` is now in BOTH `RUNTIME_OBSERVATION_ROLES` and `PR_INTERACTION_ROLES`, so it uploads the captures to the relevant PR/MR (mode-aware target the dispatch builder injects: discrete/discrete-hybrid → the stage's `stage_prs[stage].url`, else the intent-main `draft_pr_url`; GitLab uploads API / GitHub release-asset) where humans audit the chain. `ViewSession` lives in `sessions.ts`; the file-serve route accepts view sessions through the same `/stage-artifacts/:sessionId/*` path-safety chain reviews use, so proof renders live off disk even though it's gitignored.\n\n**Reflection surface (2026-05-19).** Engine prompt bodies live at `plugin/prompts//` (single source of truth, shipped with the plugin) with a two-tier cascade — project override at `.haiku/prompts/` beats plugin default at `/`. `loadTemplate` is mtime-cached; the build-time `canonicalize-prompt-templates` esbuild plugin rewrites every `loadTemplate(import.meta.url, …)` call site in the TS builders under `packages/haiku/src/orchestrator/prompts/` to a `\"@canon:\"` sentinel form, no copy step needed since `plugin/prompts/` ships directly. On top of that, two cursor actions fire per intent when reflection is enabled (default-on; opt-out via `reflection: false` on intent.md FM, with `autotune: false` honored for backward compatibility): `record_observations` fires once per stage right before `complete_stage` so the agent writes a free-form `stages//observations.md` capturing the out-of-band churn that FBs / outputs / iterations don't already show. `record_reflection` fires once at intent close right before `seal_intent` so the agent reads every observations.md + the full FB stream + unit iterations/outputs and (a) writes a synthesized `reflection.md` at intent root, (b) lands override-class findings as project overlays directly under `.haiku/...` (cascade picks them up next tick, no rebuild needed), (c) reports engine-class findings via the existing `haiku_report` tool with the message prefixed `[autotune engine-class]` (existing Sentry pipeline scrubs the payload). All edits commit with an `autotune:` prefixed message so PR review surfaces provenance — no FB queue, no tune dashboard, no per-file frontmatter stamps.", + "**The cursor model — v4's reconciliation point.** `derivePosition(slug)` reads disk and walks three tracks in priority order:\n\n1. **Track C — drift sweep.** Re-hashes each unit's body / declared outputs and compares against the FM witness (`reviews..body_sha256`, `approvals..witnesses[]`). Discovery is NOT a witnessed surface — its signal is artifact existence at the studio template's `location:`, so there's no stamp to drift against. Mismatch → `drift_detected { events }`. Dedup'd against open drift FBs by `source_ref` so a fired FB suppresses re-emission until it closes. Pre-v4 baseline artifacts (`baseline.json`, `drift-markers.json`, `baseline-content/`) are deleted by the v0→v4 migrator.\n\n2. **Track B — feedback.** Walks every stage from index 0 through the active stage, then intent-scope. Open FB → `start_feedback_hat` (next fix-hat dispatch) or `close_feedback` (terminal advance landed). Cross-stage routing is purely by file location: an FB in `stages//feedback/` rewinds the cursor to that stage's fix loop on the next tick. There is no `upstream_stage:` field and no pre-tick triage gate — classification is the first hat in the stage's `fix_hats:` chain (calls `haiku_feedback_set_targets`).\n\n3. **Track A — intent.** Pre-stage walk fires `elaborate_loop` (no `stage` field, `signals_unmet: [{signal: \"verify_conversation\"}]`) when `intent.md` lacks `verified_at` on a fresh non-autopilot intent (grandfathered when stage work has already shipped). On the active stage (first stage whose branch is not merged into intent main), the cursor walks the per-stage state machine in lifecycle order: `elaborate_loop` (single state carrying every unmet completion signal — `conversation` / `verify_conversation` / `discovery` / `decompose` / `verify_decompose`) → **`dispatch_review` / `user_gate { gate_kind: \"spec\" }` (PRE-execute — audits the SPEC before any code lands)** → `start_unit_hat` (wave logic; while `decompose_verified_at` is absent the loop keeps emitting `verify_decompose` and blocks wave dispatch) → `dispatch_quality_gates` → **`dispatch_approval` / `user_gate { gate_kind: \"approval\" }` (POST-execute — audits the WORK against the already-approved spec)** → `complete_stage`. The pre/post split landed 2026-05-17: `dispatch_review` moved from post-execute to pre-execute; `dispatch_approval` stayed post-execute; engine-built roles fire in BOTH walks with phase-appropriate mandate bodies.\n\n**Elaborate loop — single state, multi-signal payload (GAPS § 1a → Option A, 2026-05-14).** The cursor emits ONE `elaborate_loop` action per tick whose `signals_unmet[]` enumerates every currently-unmet completion signal. The agent may make progress on any subset in the same response — the loop is concurrent at the action shape, not just at the prompt level. The cursor recomputes on the next tick and either returns the still-unmet subset or falls through past the loop. Signals: `conversation` (elaboration.md missing on a fresh stage) / `verify_conversation` (recorded but unverified — carries the verifier nonce at `verifier_nonces.verify_conversation`) / `discovery` (one entry per missing discovery template, carries `agent` + representative `units`) / `decompose` (units.length === 0) / `verify_decompose` (units exist + `decompose_verified_at` missing — carries `verifier_nonces.verify_decompose`). Discovery subagents file `origin: discovery, resolution: question` FBs when surfacing user-decisions; the next tick routes these as `feedback_question` via Track B before the loop continues.\n\n**Verifier nonces (GAPS § 3, 2026-05-14).** The seal tools (`haiku_intent_seal`, `haiku_stage_elaboration_seal`, `haiku_stage_decompose_seal`) require the per-signal `nonce` from `verifier_nonces.`. Nonces are minted by the cursor's wire layer when the matching signal is emitted, persisted at `.haiku/intents//.verifier-nonces.json`, and consumed (deleted) on a successful seal. A confused or main-agent caller that invokes a seal tool without the dispatched verifier's nonce gets `verifier_nonce_invalid` back. Re-recording the elaboration artifact clears the stage-scoped nonces so a stale verifier dispatched against the old body can't seal the new one.\n\nAfter every stage merges, the cursor walks intent-scope approvals (`spec`, `continuity`, `cross-stage-consistency`, studio intent-completion review agents from `intent-review-agents/`, `user`) and emits `intent_review` per missing role. Once every approval is signed and reflection is done, the cursor checks delivery (`intent-delivery.ts`): if `haiku//main` has NOT landed on the repo's default branch it emits `pending_seal` and HOLDS — `sealed_at` stays unwritten and the engine never merges (honors \"never merge unless asked\"). On `/haiku:haiku-pickup` the delivery PR-interaction approval (`delivery-verifier`) is re-opened so it re-audits the open change request and files feedback for new review comments. Once the hub branch is an ancestor of the default branch (merged locally or via a merged PR/MR) the cursor emits `seal_intent`, then `sealed`. Filesystem mode / no resolvable default branch → the gate is inapplicable and it seals straight away.\n\n**Pre-cursor selection gates** — `run-tick.ts` (between migrator and `derivePosition`) emits `select_studio` / `select_mode` / `select_stage` when `intent.studio` / `intent.mode` is unset, or when mode is `quick` and `intent.stages[]` is empty. `haiku_run_next` blocks on the picker UI inline; the agent never sees a \"call haiku_select_*\" instruction.\n\n**Pre-cursor worktree gates (git-layer; the cursor reads none of it, Rule 1).** Also in `run-tick.ts` before `derivePosition`: `resetLostUnits` clears a unit's iterations when its isolation worktree AND branch are gone everywhere (cross-machine pickup with no recoverable ref — gated on `hasGitRemote()`), so the cursor re-dispatches the first hat into a fresh worktree. `completePendingFixChainMerges` re-attempts a fix-chain's terminal merge (via `mergeFixChainWorktree`'s `MERGE_HEAD` re-entry) for advance-closed chains whose worktree survived a conflict, completing it before a stage can advance over stranded code — or returning `integrate_fix_chains` while conflicts remain. Both no-op in filesystem mode and for the common in-flight / clean-merge paths.\n\n**No state.json.** v4 derives stage position from FM. The cursor is straight TypeScript, deterministic given the same disk state, with no LLM in the workflow-position decision. The agent does not hold workflow state in their context — anything they think they remember about waves or hats is incidental; the next tick tells them what's actually next.\n\n**Engine-built review roles (fire in BOTH walks, 2026-05-17):** `spec` (cross-unit acceptance criteria coverage, scope creep, cross-unit drift), `continuity` (handoff fidelity between hats / waves / stages), `cross-stage-consistency` (alignment with what earlier stages produced). No per-studio mandate file for any of these — `dispatch_review` renders pre-execute prose from `prompts/stage/review/dispatch_review/engine-bodies/.eta.md`, and `dispatch_approval` renders post-execute prose from `prompts/stage/approve/dispatch_approval/engine-bodies/.eta.md`.\n\n**Studio review-agent / hat / fix-hat cascade (three-tier, 2026-05-17):** the resolver walks `project/.haiku/studios//stages//review-agents/.md` → `plugin/studios//stages//review-agents/.md` → `plugin/studios//review-agents/.md` → `plugin/review-agents/.md`. First hit wins. Same cascade for `hats/` and `fix-hats/`. Studio-level intent-completion review agents now live at `plugin/studios//intent-review-agents/` (renamed from `review-agents/` to free the studio tier for stage-scope agents).\n\n**Mode shaping (read from `intent.mode`):**\n• `continuous` — full role lists `[spec, continuity, cross-stage-consistency, , user]` (reviews) and `[spec, continuity, cross-stage-consistency, quality_gates, , user]` (approvals). Runtime-observation roles (`RUNTIME_OBSERVATION_ROLES` in `orchestrator/review-role-classes.ts` — currently `runtime-verifier`) are EXCLUDED from the PRE-execute reviews list (`stageRoleLists` filters them — there's no built work to drive before execution); they fire only in approvals + intent-completion.\n• `discrete` — same role lists; a DRAFT stage PR is opened at stage START (`workflowStartStage` → `openStageDraftPullRequest`, branch `haiku//` → base `haiku//main`, stamped in the `stage_prs` map) so proof + work land on it, and the `user` gate flips it draft→ready (`markPullRequestReady`, NOT a second PR) — merge into intent main is the approval signal.\n• `discrete-hybrid` — discrete up to a chosen pivot stage, then continuous; per-stage gate type drives the dispatch choice. Per-stage draft PRs open ONLY for stages whose `review:` gate is/includes `external` (`stageRequiresExternalReview`); the continuous stages keep work on the intent-main PR.\n• `autopilot` — trimmed: reviews `[spec, continuity, cross-stage-consistency]`, approvals `[spec, continuity, cross-stage-consistency, quality_gates]`, no user gate, no studio agents, `complete_stage` auto-fires once the post-execute walk completes.\n• `quick` — single-stage intent (`intent.stages[]` length 1 after `select_stage`).\n\n**Stage→main merge serialization.** Every `complete_stage` runs under `withIntentMainLock` so concurrent stages can't race the merge into intent main. Stages are NEVER sealed — only intents are; a previously-merged stage that gains a new unit (via fix-loop corrective work) becomes ahead-of-main and `firstUnmergedStage` rewinds the cursor to it automatically.\n\n**MCP tool surface** lives in `packages/haiku/src/orchestrator.ts`, `state-tools.ts`, and `server.ts` — including `haiku_run_next` (the tick + blocking shell for every interactive UI), the unit/feedback CRUDL family with TypeBox + AJV input gates, `haiku_intent_*`, `haiku_select_*` (resume entry points; canonical path is engine-side blocking via run_next), `haiku_await_gate`, `haiku_record_agent_write`, `haiku_review_stamp` (stage review/approval subagent closure — stamps `reviews.`/`approvals.` without a cursor walk, so parallel review siblings don't trip the loop guard), `haiku_feedback_advance_hat` / `_reject_hat` / `_set_targets` / `_set_severity` / `_move`, `haiku_unit_advance_hat` / `_reject_hat`, `haiku_settings_get/set`, `haiku_studio_*`, `haiku_capacity`, `haiku_repair`, `haiku_write_brief` (engine-owned BRIEF.md write — body only; intent/stage/phase all engine-resolved), and the provider-auth family `haiku_auth_login` / `haiku_auth_status` / `haiku_auth_logout` / `haiku_upload_proof` (haikumethod.ai OAuth broker → token in `~/.haiku/settings.json`; the stored token also routes PR/MR create + mark-ready over the provider REST API via `provider-rest.ts`, falling back to the gh/glab CLI). Numeric `feedback_id` at the wire (display label `FB-001`); the parser accepts both 2-digit and 3-digit filename forms.\n\n**Runtime-verifier surface (2026-05-18).** Bundled `@playwright/mcp@latest --headless` ships in `plugin/.mcp.json` alongside two new state tools: `haiku_view` opens a view session and returns a URL the agent hands to Playwright (boot mode auto-detects the project's `npm run dev` / `bun run dev` / equivalent and spawns it on an ephemeral port; viewer mode tunnels to the SPA's `/view/?artifact=&stage=` artifact-browser route with mime dispatch covering markdown / image / PDF / SVG / source highlight / HTML and specialized web-component viewers for KiCanvas (`.kicad_sch` / `.kicad_pcb`), Tracespace (`.gbr` / `.drl`), `` (`.glb` / `.gltf`), and tscircuit (circuit `.tsx`)). `haiku_view_close` shuts the session + kills any spawned dev server. Lifecycle is layered: explicit close → 30min TTL → tick-scoped orphan sweep via `killAllOrphanedBootSessions` in `run-tick.ts` → process-exit SIGTERM. Runtime-verifier review-agents (`runtime-verifier.md` mandates at stage-tier `review-agents/` and studio-tier `intent-review-agents/`) call `haiku_view` for the boot URL, then drive it with a SELF-INSTALLED Playwright script (own scratch dir, never the project's deps; the Playwright MCP is the fallback) that RECORDS VIDEO + step screenshots into `.haiku/intents//[stages//]proof/`. That `proof/` tree is GITIGNORED (regenerated binary churn — `ensureHaikuGitignored` seeds the globs). `runtime-verifier` is now in BOTH `RUNTIME_OBSERVATION_ROLES` and `PR_INTERACTION_ROLES`, so it uploads the captures to the relevant PR/MR (mode-aware target the dispatch builder injects: discrete/discrete-hybrid → the stage's `stage_prs[stage].url`, else the intent-main `draft_pr_url`; GitLab uploads API / GitHub release-asset) where humans audit the chain. `ViewSession` lives in `sessions.ts`; the file-serve route accepts view sessions through the same `/stage-artifacts/:sessionId/*` path-safety chain reviews use, so proof renders live off disk even though it's gitignored.\n\n**Reflection surface (2026-05-19).** Engine prompt bodies live at `plugin/prompts//` (single source of truth, shipped with the plugin) with a two-tier cascade — project override at `.haiku/prompts/` beats plugin default at `/`. `loadTemplate` is mtime-cached; the build-time `canonicalize-prompt-templates` esbuild plugin rewrites every `loadTemplate(import.meta.url, …)` call site in the TS builders under `packages/haiku/src/orchestrator/prompts/` to a `\"@canon:\"` sentinel form, no copy step needed since `plugin/prompts/` ships directly. On top of that, two cursor actions fire per intent when reflection is enabled (default-on; opt-out via `reflection: false` on intent.md FM, with `autotune: false` honored for backward compatibility): `record_observations` fires once per stage right before `complete_stage` so the agent writes a free-form `stages//observations.md` capturing the out-of-band churn that FBs / outputs / iterations don't already show. `record_reflection` fires once at intent close right before `seal_intent` so the agent reads every observations.md + the full FB stream + unit iterations/outputs and (a) writes a synthesized `reflection.md` at intent root, (b) lands override-class findings as project overlays directly under `.haiku/...` (cascade picks them up next tick, no rebuild needed), (c) reports engine-class findings via the existing `haiku_report` tool with the message prefixed `[autotune engine-class]` (existing Sentry pipeline scrubs the payload). All edits commit with an `autotune:` prefixed message so PR review surfaces provenance — no FB queue, no tune dashboard, no per-file frontmatter stamps.", }, webui: { icon: "🌐", diff --git a/website/lib/browse/url.test.ts b/website/lib/browse/url.test.ts new file mode 100644 index 000000000..92904e7c6 --- /dev/null +++ b/website/lib/browse/url.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "vitest" +import { buildBrowseUrl, parseBrowsePath } from "./url" + +// parseBrowsePath takes the Next.js catch-all segment ARRAY (not a pathname); +// buildBrowseUrl returns a trailing-slash keyword path. Coverage focuses on +// the feedback deep links added for the statusline feedback chips, plus the +// pre-existing intent/stage/unit shapes they must not regress. + +describe("parseBrowsePath — project / intent / stage / unit", () => { + it("project-only", () => { + expect(parseBrowsePath(["github.com", "org", "repo"])).toEqual({ + host: "github.com", + project: "org/repo", + }) + }) + + it("intent / stage / unit (keyword form)", () => { + expect( + parseBrowsePath([ + "github.com", + "org", + "repo", + "intent", + "my-feature", + "stage", + "dev", + "unit", + "unit-01", + ]), + ).toEqual({ + host: "github.com", + project: "org/repo", + intent: "my-feature", + stage: "dev", + unit: "unit-01", + }) + }) +}) + +describe("parseBrowsePath — feedback deep links", () => { + it("stage-scoped feedback", () => { + expect( + parseBrowsePath([ + "github.com", + "org", + "repo", + "intent", + "my-feature", + "stage", + "dev", + "feedback", + "FB-007", + ]), + ).toEqual({ + host: "github.com", + project: "org/repo", + intent: "my-feature", + stage: "dev", + feedback: "FB-007", + }) + }) + + it("intent-scoped feedback (no stage)", () => { + expect( + parseBrowsePath([ + "github.com", + "org", + "repo", + "intent", + "my-feature", + "feedback", + "FB-007", + ]), + ).toEqual({ + host: "github.com", + project: "org/repo", + intent: "my-feature", + feedback: "FB-007", + }) + }) + + it("a feedback id is never mis-parsed as a unit", () => { + const loc = parseBrowsePath([ + "github.com", + "org", + "repo", + "intent", + "i", + "stage", + "dev", + "feedback", + "FB-1", + ]) + expect(loc?.unit).toBeUndefined() + expect(loc?.feedback).toBe("FB-1") + expect(loc?.stage).toBe("dev") + }) + + it("GitLab subgroup project path is preserved", () => { + expect( + parseBrowsePath([ + "gitlab.com", + "group", + "sub", + "proj", + "intent", + "i", + "feedback", + "FB-2", + ]), + ).toEqual({ + host: "gitlab.com", + project: "group/sub/proj", + intent: "i", + feedback: "FB-2", + }) + }) +}) + +describe("buildBrowseUrl — feedback", () => { + it("round-trips a stage-scoped feedback URL", () => { + const url = buildBrowseUrl({ + host: "github.com", + project: "org/repo", + intent: "my-feature", + stage: "dev", + feedback: "FB-007", + }) + expect(url).toBe( + "/browse/github.com/org/repo/intent/my-feature/stage/dev/feedback/FB-007/", + ) + const back = parseBrowsePath(url.replace(/^\/browse\/|\/$/g, "").split("/")) + expect(back?.feedback).toBe("FB-007") + expect(back?.stage).toBe("dev") + }) + + it("round-trips an intent-scoped feedback URL", () => { + const url = buildBrowseUrl({ + host: "github.com", + project: "org/repo", + intent: "my-feature", + feedback: "FB-007", + }) + expect(url).toBe( + "/browse/github.com/org/repo/intent/my-feature/feedback/FB-007/", + ) + }) + + it("feedback takes precedence over unit in the builder", () => { + const url = buildBrowseUrl({ + host: "h", + project: "p", + intent: "i", + stage: "s", + unit: "unit-01", + feedback: "FB-1", + }) + expect(url).toBe("/browse/h/p/intent/i/stage/s/feedback/FB-1/") + }) +}) diff --git a/website/lib/browse/url.ts b/website/lib/browse/url.ts index 1a5ce4d9d..c5da737bb 100644 --- a/website/lib/browse/url.ts +++ b/website/lib/browse/url.ts @@ -1,6 +1,7 @@ // H·AI·K·U Browse — path-based URL builder and parser // // URL pattern: /browse/{host}/{...project}/[intent/{slug}/[stage/{stage}/[{unit}/]]] +// Feedback: …/intent/{slug}/feedback/{id} or …/intent/{slug}/stage/{stage}/feedback/{id} // Special views: /browse/{host}/{...project}/board/ // Branch param: ?branch=feature @@ -10,6 +11,11 @@ export interface BrowseLocation { intent?: string stage?: string unit?: string + /** Feedback finding id (e.g. `FB-007`) for a feedback deep link. When set, + * `stage` is the finding's stage scope (absent for an intent-scoped FB). + * Mutually exclusive with `unit` in a URL — a feedback link targets a + * finding, a unit link targets a unit. */ + feedback?: string view?: "board" branch?: string } @@ -32,6 +38,12 @@ const RESERVED_KEYWORDS = new Set(["intent", "board"]) * * buildBrowseUrl({ host: "github.com", project: "org/repo", intent: "add-login", stage: "dev", unit: "unit-01" }) * → "/browse/github.com/org/repo/intent/add-login/stage/dev/unit/unit-01/" + * + * buildBrowseUrl({ host: "github.com", project: "org/repo", intent: "add-login", stage: "dev", feedback: "FB-007" }) + * → "/browse/github.com/org/repo/intent/add-login/stage/dev/feedback/FB-007/" + * + * buildBrowseUrl({ host: "github.com", project: "org/repo", intent: "add-login", feedback: "FB-007" }) + * → "/browse/github.com/org/repo/intent/add-login/feedback/FB-007/" (intent-scoped) */ export function buildBrowseUrl(loc: BrowseLocation): string { const base = `/browse/${loc.host}/${loc.project}` @@ -39,7 +51,14 @@ export function buildBrowseUrl(loc: BrowseLocation): string { let path: string if (loc.intent) { path = `${base}/intent/${loc.intent}/` - if (loc.stage) { + if (loc.feedback) { + // Feedback deep link: optional `stage//` scope, then + // `feedback//`. Takes precedence over `unit` — a URL targets a + // finding OR a unit, never both. + path = loc.stage + ? `${base}/intent/${loc.intent}/stage/${loc.stage}/feedback/${loc.feedback}/` + : `${base}/intent/${loc.intent}/feedback/${loc.feedback}/` + } else if (loc.stage) { path = `${base}/intent/${loc.intent}/stage/${loc.stage}/` if (loc.unit) { // `unit/` keyword mirrors `intent/` and `stage/` — each level of the @@ -122,6 +141,23 @@ export function parseBrowsePath(segments: string[]): BrowseLocation | null { if (keyword === "intent") { const remaining = segments.slice(keywordIndex + 1) if (remaining.length >= 1) loc.intent = remaining[0] + + // Feedback anchor: `…/feedback/{id}` is a distinct leaf from `unit`. + // The segment AFTER `feedback` is the id; a `stage/{stage}` before it + // (when present) is the finding's scope. Handle this first so a feedback + // id is never mis-parsed as a unit (the keyword disambiguates the leaf). + // intent/{slug}/feedback/{id} → intent-scoped + // intent/{slug}/stage/{stage}/feedback/{id} → stage-scoped + const fbIdx = remaining.indexOf("feedback") + if (fbIdx !== -1) { + loc.feedback = remaining[fbIdx + 1] + // stage scope: `stage/{stage}` immediately precedes `feedback`. + if (fbIdx >= 2 && remaining[fbIdx - 2] === "stage") { + loc.stage = remaining[fbIdx - 1] + } + return loc + } + // Parse stage: either "stage/{name}" keyword format or legacy "{name}" positional format if (remaining.length >= 3 && remaining[1] === "stage") { // New format: intent/{slug}/stage/{stage}[/unit/{unit}]