@terreno/mcp 57.11.0 → 57.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,89 @@
1
+ # Upgrading to 57.12.0
2
+
3
+ **Action required:** Yes
4
+ **Affected packages:** @terreno/ai
5
+
6
+ ## Summary
7
+
8
+ Harness tasks can record a trace scope, tags, and prompt versions, and `@terreno/ai` publishes official subpath exports. The chat `Project` model registers on first use instead of at import, and harness task HTTP responses no longer include the lease fencing token.
9
+
10
+ ## Breaking changes
11
+
12
+ ### `Project` is no longer registered on import
13
+
14
+ **What changed**
15
+
16
+ Importing `@terreno/ai` does not register mongoose model `Project`. The exported `Project` is a lazy proxy. The first property access, `new Project(...)`, or `getProjectModel()` registers the model. Statics and `instanceof Project` still work. These do not:
17
+
18
+ - `Project === mongoose.models.Project` (and `Project === mongoose.model("Project")`)
19
+ - `Object.keys(Project)` (always empty)
20
+ - reading `Project.prototype` (throws)
21
+
22
+ `new AiApp()` still mounts `/gpt/projects` and registers the model when those routes load. `new AiApp({projects: false})` skips those routes.
23
+
24
+ **Why**
25
+
26
+ An app that imports `@terreno/ai` and owns its own model named `Project` was hitting a mongoose overwrite error at import time.
27
+
28
+ **Migration**
29
+
30
+ ```typescript
31
+ // Before
32
+ import {Project} from "@terreno/ai";
33
+
34
+ const project = await Project.findExactlyOne({_id: id});
35
+ if (mongoose.models.Project === Project) {
36
+ // identity check
37
+ }
38
+
39
+ // After
40
+ import {getProjectModel} from "@terreno/ai";
41
+
42
+ const Project = getProjectModel();
43
+ const project = await Project.findExactlyOne({_id: id});
44
+ ```
45
+
46
+ To keep a separate app model named `Project`, do not call `getProjectModel()` or touch the `Project` export, and skip the AI project routes:
47
+
48
+ ```typescript
49
+ new AiApp({aiService, projects: false});
50
+ ```
51
+
52
+ ### Harness task HTTP responses omit `lease`
53
+
54
+ **What changed**
55
+
56
+ `GET /harness/tasks/:id` no longer includes `lease`. The OpenAPI schema for that response omits it too. Collection list is disabled (`list: []`, 405). The token is still stored on the task document for runners.
57
+
58
+ **Why**
59
+
60
+ `lease.token` is the fencing token that decides which runner may commit. It is not a client field.
61
+
62
+ **Migration**
63
+
64
+ ```typescript
65
+ // Before
66
+ const token = task.lease?.token;
67
+
68
+ // After
69
+ // Do not read lease from the HTTP task. Runners load it on the server.
70
+ ```
71
+
72
+ Regenerate the OpenAPI SDK after upgrading `@terreno/ai` so generated task types drop `lease`.
73
+
74
+ ## Deprecations
75
+
76
+ `Project` from `@terreno/ai` still works for `find`, `create`, `new`, and `instanceof`. It is deprecated. Call `getProjectModel()` and use that model. A later major can remove the proxy.
77
+
78
+ ## New capabilities
79
+
80
+ - `harness.createTask(def, input, {trace: {scope, tags}, prompts})` writes a scope (tenant, workspace), tags, and prompt versions onto the task's `ObsTrace`. Child tasks and subagents share that trace. The admin trace list filters with `?scope=`.
81
+ - `rt.runAgent` accepts `prompts` and a registry `PromptVersionRef` as `instructions`. It runs `body` and records the version on the trace.
82
+ - Subpath exports: `@terreno/ai/admin`, `@terreno/ai/harness/agentLoop`, `@terreno/ai/harness/commit`, `@terreno/ai/harness/events`, `@terreno/ai/harness/internalRuntime`, `@terreno/ai/observability/observabilityApp`, `@terreno/ai/observability/localPlugin`, `@terreno/ai/observability/promptStore`.
83
+
84
+ ## Verification
85
+
86
+ - Code that imported `Project` from `@terreno/ai`: switch identity checks and `mongoose.models.Project` reads to `getProjectModel()`.
87
+ - An app that owns a model named `Project`: pass `projects: false` and do not call `getProjectModel()`.
88
+ - Harness clients: confirm `GET /harness/tasks/:id` JSON has no `lease`, then regenerate the SDK.
89
+ - `bun run compile` and `bun run lint` at the app root.
@@ -49,9 +49,10 @@ Maintains centralized agent guidance and generates the matching configuration fo
49
49
 
50
50
  Rulesync generates native hooks from `.rulesync/hooks.json`. Biome checks changed files
51
51
  after edits and staged files before commits. At agent stop,
52
- `.rulesync/hooks/quality-check.sh` runs lint, TypeScript compilation, Knip, and
53
- dependency-cruiser. It keeps stdout machine-readable for each host and prevents
54
- retry-triggered Stop hooks from rerunning the commands.
52
+ `.rulesync/hooks/quality-check.sh` runs `bun install --frozen-lockfile` when
53
+ `node_modules/typescript` or any workspace package link is missing, then runs lint,
54
+ TypeScript compilation, Knip, and dependency-cruiser. It keeps stdout machine-readable
55
+ for each host and prevents retry-triggered Stop hooks from rerunning the commands.
55
56
 
56
57
  Existing findings are ratcheted so the checks reject new debt without requiring unrelated
57
58
  cleanup. See [Static analysis](static-analysis.md).
@@ -402,7 +402,7 @@ do not claim completion until done.
402
402
  | Workflow | Trigger | Purpose |
403
403
  | -------- | ------- | ------- |
404
404
  | [`.github/workflows/triage.yml`](https://github.com/TerrenoLabs/terreno/blob/master/.github/workflows/triage.yml) | Issue opened | `status:needs-triage` + `area:*` from package dropdown + `type:*` from Kind when present; `area:*` / `type:*` labels the issue was opened with win, so `roadmap:sync` issues are not asked for an area |
405
- | [`.github/workflows/roadmap-generate.yml`](https://github.com/TerrenoLabs/terreno/blob/master/.github/workflows/roadmap-generate.yml) | Daily + manual | `roadmap:sync --check` for board drift, then regenerate `ROADMAP.md` from the board and open a pull request when it changes |
405
+ | [`.github/workflows/roadmap-generate.yml`](https://github.com/TerrenoLabs/terreno/blob/master/.github/workflows/roadmap-generate.yml) | Daily + manual | `roadmap:sync --check` for board drift, then regenerate `ROADMAP.md` from the board and open a pull request when roadmap content changes. A new **Last updated** timestamp alone does not open a pull request |
406
406
  | [`.github/workflows/roadmap-sync.yml`](https://github.com/TerrenoLabs/terreno/blob/master/.github/workflows/roadmap-sync.yml) | Taxonomy files change on `master` + manual | Apply labels and reconcile the board's fields and items |
407
407
  | [`.github/workflows/roadmap-reconcile.yml`](https://github.com/TerrenoLabs/terreno/blob/master/.github/workflows/roadmap-reconcile.yml) | IP or task files change on `master` + manual | Advance status from IP headers, add an entry for every new IP, open its tracking issue, push to the board, regenerate `ROADMAP.md` |
408
408
  All three share the `roadmap` concurrency group. They write the same board, and interleaving
@@ -495,4 +495,6 @@ GITHUB_TOKEN=$(gh auth token) TERRENO_PROJECT_NUMBER=... bun run roadmap:generat
495
495
  ```
496
496
 
497
497
  The generator exits non-zero when the project cannot be read, so a bad project number or a
498
- token without `read:project` fails loudly instead of writing an empty `ROADMAP.md`.
498
+ token without `read:project` fails loudly instead of writing an empty `ROADMAP.md`. When the
499
+ rendered list matches the file already on disk, it keeps that file's **Last updated** stamp
500
+ and does not write. The daily workflow then sees a clean diff and does not open a pull request.
@@ -254,8 +254,12 @@ the review script so a PR cannot rewrite the reviewer.
254
254
  Netlify and GCP jobs **validate their context as the first step** and fail,
255
255
  before checkout or `bun install`, listing the missing variables. Preview jobs
256
256
  halt earlier on fork PRs, where CircleCI withholds contexts. Docker Layer
257
- Caching is off (200 credits per job). The docs Netlify target disables
258
- Docusaurus minification so the build stays within the 8 GB `large` executor.
257
+ Caching is off (200 credits per job). The docs Netlify target builds only the
258
+ current docs tree, disables Docusaurus minification, and pins SSG to one worker
259
+ thread so the build peaks near 3 GB and fits the 4 GB `medium` executor.
260
+ Released versions are not rebuilt: `archive-docs-version` (in `publish-release`,
261
+ `X.Y.0` tags only) builds each one once and uploads it to its GitHub release, and
262
+ `deploy-docs` unpacks those archives under `/<version>/`. See `website/README.md`.
259
263
 
260
264
  `terreno-gcp` uses CircleCI OIDC (`CIRCLE_OIDC_TOKEN_V2`), never a JSON service
261
265
  account key. Set `circleci_org_id`, `circleci_project_id`, and
@@ -7,24 +7,23 @@ describes how they behaved if one is ever re-enabled.
7
7
 
8
8
  ## Docs site (`docs-deploy.yml`)
9
9
 
10
- `Build docs site` is often the longest PR job because Docusaurus compiles every
11
- versioned tree under `website/versioned_docs/` and TypeDoc regenerates API
12
- pages.
10
+ `Build docs site` is often the longest PR job because TypeDoc regenerates API
11
+ pages. The site builds only the current docs tree; `website/versioned_docs/` is
12
+ markdown for the MCP server.
13
13
 
14
14
  PR builds set `DOCS_PREVIEW=true` and pass `--no-minify`. Both PR and
15
15
  `master` use Docusaurus Faster (Rspack/SWC via `@docusaurus/faster`).
16
16
 
17
17
  | Behavior | PR preview | `master` production |
18
18
  | --- | --- | --- |
19
- | Versioned docs (`57.1.0`, `0.30.0`, …) | Omitted (`disableVersioning`) | Built |
19
+ | Versioned docs (`57.1.0`, `0.30.0`, …) | Omitted | Prebuilt archives unpacked under `/<version>/` |
20
20
  | JS minify | Off | On |
21
21
  | Local search index | Skipped | Built |
22
22
  | Generated API + component MDX | Restored from cache when `api`/`rtk`/`ui` hashes match | Same cache, then full generate on miss |
23
23
  | TypeDoc workspace `tsc` | Once per generate (api+rtk deps share a process) | Same |
24
24
  | Rspack cache (`node_modules/.cache/rspack`) | Restored per `pull_request` vs `push` | Separate production key |
25
25
 
26
- Production still builds every version. Do not rely on `/57.1.0/…` URLs in a
27
- PR deploy preview.
26
+ Do not rely on `/57.1.0/…` URLs in a PR deploy preview.
28
27
 
29
28
  ## Other high-cost jobs
30
29
 
@@ -142,7 +142,7 @@ The `task` view passed to `run`: `{id, name, version, input, state, phase, attem
142
142
  | `Harness.open({registry, runner?, models?, env?, priceMap?, streaming?, testHooks?})` | Checks requirements, rejects a duplicate `name@version` or agent name, ensures collections and indexes exist. `runner` defaults to `new InProcessRunner()`. See [open options](#open-options). |
143
143
  | `start()` | Throws, claiming nothing, when any non-terminal task uses a `name@version` missing from the registry (see [Versioning](#versioning)). Then starts the runner. Throws when already started. The runner recovers expired tasks once it owns execution. |
144
144
  | `stop()` | Stops claiming work, waits (without a time limit) for every phase in flight while still renewing the owner lease, then releases it. No-op when not started. |
145
- | `createTask(definition, input, {requestId?, userId?})` | Inserts a `pending` task, its `ObsTrace`, and its root span in one transaction. Wakes the runner. The definition must be in the registry. |
145
+ | `createTask(definition, input, {requestId?, userId?, trace?, prompts?})` | Inserts a `pending` task, its `ObsTrace`, and its root span in one transaction. Wakes the runner. The definition must be in the registry. See [Trace scope and prompt versions](#trace-scope-and-prompt-versions). |
146
146
  | `resolveInterrupted(id, {action, reason, result?, userId?})` | Resolve an `interrupted` task. See [resolveInterrupted](#resolveinterrupted). |
147
147
  | `abort(id, {reason, userId?})` | Abort a task and every non-terminal task it owns, bottom-up. See [abort](#abort). |
148
148
  | `decideApproval(approvalId, {approved, reason?, userId?})` | Approve or reject a pending approval without checking approvers. See [Approvals](#approvals). |
@@ -158,6 +158,30 @@ create with the same id returns the first task and leaves no extra trace. Reusin
158
158
  `requestId` for a different task name, or with a different `userId` (including none versus
159
159
  some), throws. A soft-deleted task still owns its `requestId`.
160
160
 
161
+ ### Trace scope and prompt versions
162
+
163
+ A task's `ObsTrace` is its run record. `createTask` can label it:
164
+
165
+ ```typescript
166
+ const prompt = await promptRegistry.get({label: "production", name: "intake-summary"});
167
+ await harness.createTask(intakeSummary, {patientId}, {
168
+ prompts: prompt ? [prompt] : [],
169
+ trace: {scope: String(workspaceId), tags: ["nightly"]},
170
+ userId,
171
+ });
172
+ ```
173
+
174
+ | Option | Type | Description |
175
+ | --- | --- | --- |
176
+ | `trace.scope` | `string` | App-defined scope, such as a tenant or workspace id. Stored as `ObsTrace.scope`, indexed with `created` for per-scope metering (sum `LLM` span `usage.costUsd` by scope and month). Blank throws `harness-invalid-request`. |
177
+ | `trace.tags` | `string[]` | App-defined labels, stored once each as `ObsTrace.tags`. A blank tag throws `harness-invalid-request`. |
178
+ | `prompts` | `{name, version, label?}[]` | Prompt versions the task uses, stored once each on `ObsTrace.prompts`. A `PromptVersionRef` from `promptRegistry.get` fits as-is; its `body` is not stored. A ref without a name, with a non-string `label`, or without a positive integer `version` throws `harness-invalid-request`. |
179
+
180
+ Child tasks and subagents write into the root task's trace, so they share its scope and
181
+ tags. `rt.runAgent` adds its own prompt versions to the same trace (see
182
+ [Subagents](#subagents-rtrunagent)). The admin trace list filters by `scope` and by
183
+ prompt.
184
+
161
185
  ### open options
162
186
 
163
187
  | Option | Type | Description |
@@ -306,7 +330,7 @@ where `lease` is `{owner, duration, heartbeat}` (Luxon `Duration`s). With `maxPh
306
330
  | `rt.signal` | `AbortSignal`. Aborts when the task is aborted (at once in this process, within one heartbeat elsewhere), or when this run loses its lease. Pass it to cancellable calls. |
307
331
  | `rt.createTask(definition, input, {background?, key?})` | Create a child task. Returns its id. See [Child tasks and waitForTasks](#child-tasks-and-waitfortasks). |
308
332
  | `rt.waitForTasks(ids, {policy?})` | Return child outcomes once they settle; until then the task waits. See [Child tasks and waitForTasks](#child-tasks-and-waitfortasks). |
309
- | `rt.runAgent(agent, {input, output?, instructions?})` | Run a registered agent as a subagent and return its answer; until it finishes the task waits. See [Subagents (rt.runAgent)](#subagents-rtrunagent). |
333
+ | `rt.runAgent(agent, {input, output?, instructions?, prompts?})` | Run a registered agent as a subagent and return its answer; until it finishes the task waits. See [Subagents (rt.runAgent)](#subagents-rtrunagent). |
310
334
  | `rt.memo(key)` / `rt.memo(key, value)` | Read, or first-write, a durable value scoped to this task. See [Memos](#memos). |
311
335
  | `rt.waitFor(event, {timeout?})` | Return the payload of the next `event` sent to this task, or `undefined` once `timeout` passes; until then the task waits. See [Events, waits, and sleep](#events-waits-and-sleep). |
312
336
  | `rt.sleep(duration)` | Return once `duration` has passed; until then the task waits. See [Events, waits, and sleep](#events-waits-and-sleep). |
@@ -817,7 +841,9 @@ is not offered to sections, `beforeModelRequest`, or `afterTool` (the tool alrea
817
841
  `basePath` defaults to `/harness`; it must start with `/` and not end with `/`.
818
842
  `heartbeatInterval` (default `{seconds: 15}`, must be positive) paces SSE heartbeats.
819
843
  Conversation, task, and approval routes are `modelRouter`s with instance actions, so they
820
- appear in `/openapi.json` (tag `harness`). The two SSE routes are plain Express routes
844
+ appear in `/openapi.json`. Each route and its mutations use that collection's tag
845
+ (`harnessconversations`, `harnesstasks`, `harnessapprovals`), so a generated client
846
+ invalidates the list it just changed. The two SSE routes are plain Express routes
821
847
  (the listed exception to modelRouter actions) and are not in the OpenAPI spec.
822
848
 
823
849
  ### Conversation routes
@@ -840,7 +866,7 @@ appear in `/openapi.json` (tag `harness`). The two SSE routes are plain Express
840
866
 
841
867
  | Method | Path | Permissions | Behavior |
842
868
  | --- | --- | --- | --- |
843
- | GET | `/harness/tasks/:id` | Owner (`userId`) or admin | One task. Others: 403. |
869
+ | GET | `/harness/tasks/:id` | Owner (`userId`) or admin | One task, without `lease` (the fencing token stays on the server). Others: 403. |
844
870
  | POST | `/harness/tasks/:id/abort` | Owner or admin | Body `{reason}` (required, non-blank). Runs `harness.abort` with the caller as `userId`; returns the aborted task. |
845
871
  | POST | `/harness/tasks/:id/resolveInterrupted` | Admin | Body `{action: "retry" \| "abort" \| "complete", reason, result?}`. Runs `harness.resolveInterrupted` with the caller as `userId`; returns the task. |
846
872
  | GET | `/harness/tasks/:id/events` | Owner or admin | SSE for the task and its descendants. See [Event stream (SSE)](#event-stream-sse). |
@@ -1320,7 +1346,8 @@ summarize: {
1320
1346
  | --- | --- | --- | --- |
1321
1347
  | `input` | `unknown` | Yes | The subagent's user message. A string is sent as-is (blank is rejected); anything else as `JSON.stringify(input)`. |
1322
1348
  | `output` | zod schema | No | Structured output. Defaults to the agent's own `output`. |
1323
- | `instructions` | `string` | No | Replaces the agent's instructions for this subagent conversation only. |
1349
+ | `instructions` | `string` or `PromptVersionRef` | No | Replaces the agent's instructions for this subagent conversation only. A registry prompt (`promptRegistry.get`) runs with its `body`, and its `{name, version, label}` is added to the trace's `prompts`. |
1350
+ | `prompts` | `{name, version, label?}[]` | No | More prompt versions this subagent used, added to the trace's `prompts`. Each version is recorded once per trace. An invalid ref, or an `instructions` prompt without a string `body`, fails the phase with a definition error. |
1324
1351
 
1325
1352
  Returns the final assistant text, or, with a schema, the object the schema parsed. The
1326
1353
  generic `rt.runAgent<T>(...)` types the result when the schema comes from the agent.
@@ -1331,6 +1358,7 @@ What one call does:
1331
1358
  (`ownership: {kind: "task", id: <caller>}`, `status: "busy"`, agent snapshot with
1332
1359
  `instructions` and `outputSchema`), its user message (`seq` 1), and the turn task
1333
1360
  (`ownership: {kind: "task", id: <caller>}`) with an `AGENT` span named after the agent.
1361
+ Prompt versions from `instructions` and `prompts` are added to the trace's `prompts`.
1334
1362
  2. Waits with `rt.waitForTasks([turn])`: the caller commits `waiting` and the phase stops.
1335
1363
  3. When the turn settles the caller wakes and the phase re-runs from its checkpoint. The
1336
1364
  same call finds the same turn and conversation (step 1 writes nothing) and returns.
@@ -91,7 +91,7 @@ src/
91
91
  - **Plugins:** `AiApp`, `LangfuseApp`
92
92
  - **Service:** `AIService`, `TemperaturePresets`, `FileStorageService`, `MCPService`,
93
93
  `getMCPTools`
94
- - **Models:** `AIRequest`, `GptHistory`, `FileAttachment`, `Project`
94
+ - **Models:** `AIRequest`, `GptHistory`, `FileAttachment`, `Project` (lazy; prefer `getProjectModel()`)
95
95
  - **Routes:** `addGptRoutes`, `addGptHistoryRoutes`, `addAiRequestsExplorerRoutes`, `addFileRoutes`, `addProjectRoutes`, `addMcpRoutes`
96
96
  - **Structured output:** `parseAiJson`, `normalizeLlmJsonTextForStructuredOutput`, re-exported `Output`, `jsonSchema`, `JSONValue`, `FlexibleSchema` from `ai`
97
97
  - **Langfuse:** `initLangfuseClient`, `getLangfuseClient`, `shutdownLangfuseClient`, `compilePrompt`, `createPrompt`, `getPrompt`, `createTelemetryConfig`, `preparePromptForAI`, `initTracing`, `shutdownTracing`, `LangfuseCache`, cache helpers
@@ -249,7 +249,8 @@ Metadata for files stored in GCS.
249
249
 
250
250
  ### Project
251
251
 
252
- GPT project with persistent context and memories.
252
+ GPT project with persistent context and memories. Registered lazily: `getProjectModel()`
253
+ (or any use of the deprecated `Project` export) registers it.
253
254
 
254
255
  | Field | Type | Description |
255
256
  |-------|------|-------------|
@@ -440,6 +441,7 @@ new AiApp({
440
441
  | `maxSteps` | Max tool-calling steps (default 5) |
441
442
  | `titleModelId` | Cheaper model for conversation title generation |
442
443
  | `openApiOptions` | Passed to route OpenAPI builders |
444
+ | `projects` | Default `true`. `false` skips the `/gpt/projects` routes, so `AiApp` never registers the `Project` model |
443
445
 
444
446
  ## LangfuseApp plugin
445
447
 
@@ -508,7 +510,7 @@ server `AIService` when configured, otherwise a request-scoped service built fro
508
510
  | `ObsPrompt` | Named prompt (`name` unique) with `folder` and `tags[]` |
509
511
  | `ObsPromptVersion` | Immutable `vN` body, `variables[]`, schemas, `sensitive` (default false), `config` |
510
512
  | `ObsPromptLabel` | Movable labels; unique `(promptId, label)` |
511
- | `ObsTrace` | Root trace: user, session, status, `errorSummary`, `sensitive`, `prompts[]`, usage |
513
+ | `ObsTrace` | Root trace: user, session, status, `errorSummary`, `sensitive`, `prompts[]`, `scope`, `tags[]`, usage |
512
514
  | `ObsSpan` | Nested span with `kind`, `status`, optional `error`, offsets, usage |
513
515
  | `ObsScore` | Scores on a trace/span; many per trace, **no unique index** |
514
516
  | `ObsEvaluator` | Evaluator: `type` (`human` \| `llm-judge` \| `json-assert`), `target`, `dimensions[]`, `runModes`, `instructions`, `judgePromptName` (judge), `assertion` (json-assert), `confidenceAlertBelow` (default 0.7) |
@@ -560,7 +562,7 @@ When `prompts.primary` is `local`, `ObservabilityApp.register` mounts admin-only
560
562
 
561
563
  | Method | Path | Behavior |
562
564
  | --- | --- | --- |
563
- | GET | `/ai/observability/traces` | Admin list. Query `from`, `to`, `prompt`, `status`, `userId`, `sessionId`, `hasScore`, `sensitive`, `flaggedForDataset`, `page`, `limit`. Body is `{data, page, limit, more, total}` so pagination survives RTK `{data}` unwrap. Each row includes `spanCount` and `scoreCount`. `prompts.length` is the `N prompts` count |
565
+ | GET | `/ai/observability/traces` | Admin list. Query `from`, `to`, `prompt`, `status`, `userId`, `sessionId`, `scope`, `hasScore`, `sensitive`, `flaggedForDataset`, `page`, `limit`. Body is `{data, page, limit, more, total}` so pagination survives RTK `{data}` unwrap. Each row includes `spanCount` and `scoreCount`. `prompts.length` is the `N prompts` count |
564
566
  | GET | `/ai/observability/traces/:id` | Span tree (kind, offsets, durations, I/O, cost) plus scores. `errorSummary` is the first span with `status: "error"` |
565
567
  | POST | `/ai/observability/traces/:id/scores` | Persist a score and fan out to every `ScoreSink` |
566
568
  | POST | `/ai/observability/traces/test-multi-stage` | Admin-only smoke workflow, registered only with the local trace sink. Uses `ObservabilityApp.aiService`, or `requestAiServiceFactory({apiKey})` when the server service is absent (`apiKey` comes from `x-ai-api-key`); answers **503** with the same missing-key title as playground when neither exists. Body `{input?: string}` (defaults to a built-in sample). Runs two `AIService.generateJsonObject` calls with `skipTrace: true` and named JSON output schemas (`obs-test-multi-stage-call-1` / `call-2`), a deterministic local `text-metrics` `TOOL` stage, then a final `generateJsonObject` synthesis against `obs-test-multi-stage-final`; exports exactly one parent trace with ordered child spans `LLM`, `LLM`, `TOOL`, `LLM` under a `CHAIN` root. LLM span input includes `outputSchema`. Returns `{traceId, output, stages[]}` where `output` is the final schema object (`sentence`, `phrase`, `keywords`, `metrics`). Child LLM failures export an error trace then rethrow |
@@ -638,6 +640,33 @@ Low-level exports (also used by `addGptRoutes` when `langfuseSystemPromptName` i
638
640
 
639
641
  Subpath imports for tree-shaking: `@terreno/ai/langfuseClient`, `@terreno/ai/langfuseApp`.
640
642
 
643
+ ## Subpath exports
644
+
645
+ Import these instead of `dist/` paths. Deep `dist/` imports break when a bundler (such as
646
+ Vercel's) inlines them and their bare `@terreno/api` imports no longer resolve. Each
647
+ subpath shares module instances with `@terreno/ai/harness`.
648
+
649
+ | Subpath | Exports |
650
+ | --- | --- |
651
+ | `@terreno/ai/harness` | Harness public API; see [AI harness reference](ai-harness.md) |
652
+ | `@terreno/ai/harness/jobsRunner` | `JobsRunner` |
653
+ | `@terreno/ai/harness/agentLoop` | `createAgentTasks` and the agent turn internals |
654
+ | `@terreno/ai/harness/commit` | `createTaskRecords`, `HarnessModels`, and other commit helpers, for custom commits that write domain records inside a task's fenced commit |
655
+ | `@terreno/ai/harness/events` | `insertMessages`, `resolveStreamingOptions` |
656
+ | `@terreno/ai/harness/internalRuntime` | `internalRuntime` |
657
+ | `@terreno/ai/admin` | `AIAdminApp` |
658
+ | `@terreno/ai/observability/observabilityApp` | `ObservabilityApp` |
659
+ | `@terreno/ai/observability/localPlugin` | `createLocalObservabilityPlugin`, `createLocalObservabilityBundle` |
660
+ | `@terreno/ai/observability/promptStore` | `LocalPromptStore` |
661
+
662
+ The internals subpaths follow the harness's own changes; they carry no compatibility
663
+ promise beyond the release they ship in.
664
+
665
+ The root `@terreno/ai` entry registers the `Project` model on first use
666
+ (`getProjectModel()` or the `Project` export), not on import. `AiApp` uses it for
667
+ `/gpt/projects` by default; an app that owns a model named `Project` passes
668
+ `new AiApp({projects: false})` and does not call `addProjectRoutes`.
669
+
641
670
  ## FileStorageService
642
671
 
643
672
  Google Cloud Storage helper for uploads referenced by `addFileRoutes`.
@@ -725,8 +725,16 @@ Master-detail layout. Pass `listViewData` plus `renderListViewItem` for the list
725
725
  screens the detail replaces the list until the user goes back.
726
726
 
727
727
  `desktopChildrenMinWidth` opts into a minimum pixel width for each desktop child when there
728
- are one or two children. `childColumnRounding` sets the border radius on each detail child
729
- column container (defaults to `md`, 4px); the list column is excluded. `narrowViewportChildLabels`
728
+ are one or two children. `childColumnRounding` sets the border radius on each desktop detail
729
+ pane (defaults to `md`, 4px); the list column is excluded. The pane clips to that radius. The
730
+ column inside it scrolls: content taller than the pane scrolls in the column, and a child with
731
+ `height: "100%"` fills the visible pane so a scroll view inside that child scrolls on its own.
732
+ On a narrow web viewport the pager page is height-bounded to that same visible pane. Its rounded
733
+ corners still clip, and a `height: "100%"` child scrolls inside the page. On the labeled pager,
734
+ previous and next sit in fixed left and right slots in a row under the column. A missing direction
735
+ leaves that slot empty, so the other arrow stays put. The column height is the measured pane minus
736
+ top spacing, the navigation row, bottom spacing, and `bottomNavBarHeight`.
737
+ `narrowViewportChildLabels`
730
738
  opts into a labeled full-width pager on the narrow viewport. `narrowBelowWidth` uses that
731
739
  viewport when the window is at or below the given pixel width; when omitted, the narrow
732
740
  viewport follows `isNarrowViewport()`. These props are web only; the native `SplitPage`
package/package.json CHANGED
@@ -11,8 +11,8 @@
11
11
  "@modelcontextprotocol/node": "^2.0.0",
12
12
  "@modelcontextprotocol/server": "^2.0.0",
13
13
  "@sentry/bun": "^10.39.0",
14
- "@terreno/api": "57.11.0",
15
- "@terreno/blocks": "57.11.0",
14
+ "@terreno/api": "57.12.0",
15
+ "@terreno/blocks": "57.12.0",
16
16
  "express": "^5.2.1",
17
17
  "luxon": "^3.7.2",
18
18
  "minisearch": "^7.2.0",
@@ -105,5 +105,5 @@
105
105
  "test:coverage": "bun run ../scripts/check-coverage.ts"
106
106
  },
107
107
  "type": "module",
108
- "version": "57.11.0"
108
+ "version": "57.12.0"
109
109
  }
@@ -0,0 +1,89 @@
1
+ # Upgrading to 57.12.0
2
+
3
+ **Action required:** Yes
4
+ **Affected packages:** @terreno/ai
5
+
6
+ ## Summary
7
+
8
+ Harness tasks can record a trace scope, tags, and prompt versions, and `@terreno/ai` publishes official subpath exports. The chat `Project` model registers on first use instead of at import, and harness task HTTP responses no longer include the lease fencing token.
9
+
10
+ ## Breaking changes
11
+
12
+ ### `Project` is no longer registered on import
13
+
14
+ **What changed**
15
+
16
+ Importing `@terreno/ai` does not register mongoose model `Project`. The exported `Project` is a lazy proxy. The first property access, `new Project(...)`, or `getProjectModel()` registers the model. Statics and `instanceof Project` still work. These do not:
17
+
18
+ - `Project === mongoose.models.Project` (and `Project === mongoose.model("Project")`)
19
+ - `Object.keys(Project)` (always empty)
20
+ - reading `Project.prototype` (throws)
21
+
22
+ `new AiApp()` still mounts `/gpt/projects` and registers the model when those routes load. `new AiApp({projects: false})` skips those routes.
23
+
24
+ **Why**
25
+
26
+ An app that imports `@terreno/ai` and owns its own model named `Project` was hitting a mongoose overwrite error at import time.
27
+
28
+ **Migration**
29
+
30
+ ```typescript
31
+ // Before
32
+ import {Project} from "@terreno/ai";
33
+
34
+ const project = await Project.findExactlyOne({_id: id});
35
+ if (mongoose.models.Project === Project) {
36
+ // identity check
37
+ }
38
+
39
+ // After
40
+ import {getProjectModel} from "@terreno/ai";
41
+
42
+ const Project = getProjectModel();
43
+ const project = await Project.findExactlyOne({_id: id});
44
+ ```
45
+
46
+ To keep a separate app model named `Project`, do not call `getProjectModel()` or touch the `Project` export, and skip the AI project routes:
47
+
48
+ ```typescript
49
+ new AiApp({aiService, projects: false});
50
+ ```
51
+
52
+ ### Harness task HTTP responses omit `lease`
53
+
54
+ **What changed**
55
+
56
+ `GET /harness/tasks/:id` no longer includes `lease`. The OpenAPI schema for that response omits it too. Collection list is disabled (`list: []`, 405). The token is still stored on the task document for runners.
57
+
58
+ **Why**
59
+
60
+ `lease.token` is the fencing token that decides which runner may commit. It is not a client field.
61
+
62
+ **Migration**
63
+
64
+ ```typescript
65
+ // Before
66
+ const token = task.lease?.token;
67
+
68
+ // After
69
+ // Do not read lease from the HTTP task. Runners load it on the server.
70
+ ```
71
+
72
+ Regenerate the OpenAPI SDK after upgrading `@terreno/ai` so generated task types drop `lease`.
73
+
74
+ ## Deprecations
75
+
76
+ `Project` from `@terreno/ai` still works for `find`, `create`, `new`, and `instanceof`. It is deprecated. Call `getProjectModel()` and use that model. A later major can remove the proxy.
77
+
78
+ ## New capabilities
79
+
80
+ - `harness.createTask(def, input, {trace: {scope, tags}, prompts})` writes a scope (tenant, workspace), tags, and prompt versions onto the task's `ObsTrace`. Child tasks and subagents share that trace. The admin trace list filters with `?scope=`.
81
+ - `rt.runAgent` accepts `prompts` and a registry `PromptVersionRef` as `instructions`. It runs `body` and records the version on the trace.
82
+ - Subpath exports: `@terreno/ai/admin`, `@terreno/ai/harness/agentLoop`, `@terreno/ai/harness/commit`, `@terreno/ai/harness/events`, `@terreno/ai/harness/internalRuntime`, `@terreno/ai/observability/observabilityApp`, `@terreno/ai/observability/localPlugin`, `@terreno/ai/observability/promptStore`.
83
+
84
+ ## Verification
85
+
86
+ - Code that imported `Project` from `@terreno/ai`: switch identity checks and `mongoose.models.Project` reads to `getProjectModel()`.
87
+ - An app that owns a model named `Project`: pass `projects: false` and do not call `getProjectModel()`.
88
+ - Harness clients: confirm `GET /harness/tasks/:id` JSON has no `lease`, then regenerate the SDK.
89
+ - `bun run compile` and `bun run lint` at the app root.
@@ -49,9 +49,10 @@ Maintains centralized agent guidance and generates the matching configuration fo
49
49
 
50
50
  Rulesync generates native hooks from `.rulesync/hooks.json`. Biome checks changed files
51
51
  after edits and staged files before commits. At agent stop,
52
- `.rulesync/hooks/quality-check.sh` runs lint, TypeScript compilation, Knip, and
53
- dependency-cruiser. It keeps stdout machine-readable for each host and prevents
54
- retry-triggered Stop hooks from rerunning the commands.
52
+ `.rulesync/hooks/quality-check.sh` runs `bun install --frozen-lockfile` when
53
+ `node_modules/typescript` or any workspace package link is missing, then runs lint,
54
+ TypeScript compilation, Knip, and dependency-cruiser. It keeps stdout machine-readable
55
+ for each host and prevents retry-triggered Stop hooks from rerunning the commands.
55
56
 
56
57
  Existing findings are ratcheted so the checks reject new debt without requiring unrelated
57
58
  cleanup. See [Static analysis](static-analysis.md).
@@ -402,7 +402,7 @@ do not claim completion until done.
402
402
  | Workflow | Trigger | Purpose |
403
403
  | -------- | ------- | ------- |
404
404
  | [`.github/workflows/triage.yml`](https://github.com/TerrenoLabs/terreno/blob/master/.github/workflows/triage.yml) | Issue opened | `status:needs-triage` + `area:*` from package dropdown + `type:*` from Kind when present; `area:*` / `type:*` labels the issue was opened with win, so `roadmap:sync` issues are not asked for an area |
405
- | [`.github/workflows/roadmap-generate.yml`](https://github.com/TerrenoLabs/terreno/blob/master/.github/workflows/roadmap-generate.yml) | Daily + manual | `roadmap:sync --check` for board drift, then regenerate `ROADMAP.md` from the board and open a pull request when it changes |
405
+ | [`.github/workflows/roadmap-generate.yml`](https://github.com/TerrenoLabs/terreno/blob/master/.github/workflows/roadmap-generate.yml) | Daily + manual | `roadmap:sync --check` for board drift, then regenerate `ROADMAP.md` from the board and open a pull request when roadmap content changes. A new **Last updated** timestamp alone does not open a pull request |
406
406
  | [`.github/workflows/roadmap-sync.yml`](https://github.com/TerrenoLabs/terreno/blob/master/.github/workflows/roadmap-sync.yml) | Taxonomy files change on `master` + manual | Apply labels and reconcile the board's fields and items |
407
407
  | [`.github/workflows/roadmap-reconcile.yml`](https://github.com/TerrenoLabs/terreno/blob/master/.github/workflows/roadmap-reconcile.yml) | IP or task files change on `master` + manual | Advance status from IP headers, add an entry for every new IP, open its tracking issue, push to the board, regenerate `ROADMAP.md` |
408
408
  All three share the `roadmap` concurrency group. They write the same board, and interleaving
@@ -495,4 +495,6 @@ GITHUB_TOKEN=$(gh auth token) TERRENO_PROJECT_NUMBER=... bun run roadmap:generat
495
495
  ```
496
496
 
497
497
  The generator exits non-zero when the project cannot be read, so a bad project number or a
498
- token without `read:project` fails loudly instead of writing an empty `ROADMAP.md`.
498
+ token without `read:project` fails loudly instead of writing an empty `ROADMAP.md`. When the
499
+ rendered list matches the file already on disk, it keeps that file's **Last updated** stamp
500
+ and does not write. The daily workflow then sees a clean diff and does not open a pull request.
@@ -254,8 +254,12 @@ the review script so a PR cannot rewrite the reviewer.
254
254
  Netlify and GCP jobs **validate their context as the first step** and fail,
255
255
  before checkout or `bun install`, listing the missing variables. Preview jobs
256
256
  halt earlier on fork PRs, where CircleCI withholds contexts. Docker Layer
257
- Caching is off (200 credits per job). The docs Netlify target disables
258
- Docusaurus minification so the build stays within the 8 GB `large` executor.
257
+ Caching is off (200 credits per job). The docs Netlify target builds only the
258
+ current docs tree, disables Docusaurus minification, and pins SSG to one worker
259
+ thread so the build peaks near 3 GB and fits the 4 GB `medium` executor.
260
+ Released versions are not rebuilt: `archive-docs-version` (in `publish-release`,
261
+ `X.Y.0` tags only) builds each one once and uploads it to its GitHub release, and
262
+ `deploy-docs` unpacks those archives under `/<version>/`. See `website/README.md`.
259
263
 
260
264
  `terreno-gcp` uses CircleCI OIDC (`CIRCLE_OIDC_TOKEN_V2`), never a JSON service
261
265
  account key. Set `circleci_org_id`, `circleci_project_id`, and
@@ -7,24 +7,23 @@ describes how they behaved if one is ever re-enabled.
7
7
 
8
8
  ## Docs site (`docs-deploy.yml`)
9
9
 
10
- `Build docs site` is often the longest PR job because Docusaurus compiles every
11
- versioned tree under `website/versioned_docs/` and TypeDoc regenerates API
12
- pages.
10
+ `Build docs site` is often the longest PR job because TypeDoc regenerates API
11
+ pages. The site builds only the current docs tree; `website/versioned_docs/` is
12
+ markdown for the MCP server.
13
13
 
14
14
  PR builds set `DOCS_PREVIEW=true` and pass `--no-minify`. Both PR and
15
15
  `master` use Docusaurus Faster (Rspack/SWC via `@docusaurus/faster`).
16
16
 
17
17
  | Behavior | PR preview | `master` production |
18
18
  | --- | --- | --- |
19
- | Versioned docs (`57.1.0`, `0.30.0`, …) | Omitted (`disableVersioning`) | Built |
19
+ | Versioned docs (`57.1.0`, `0.30.0`, …) | Omitted | Prebuilt archives unpacked under `/<version>/` |
20
20
  | JS minify | Off | On |
21
21
  | Local search index | Skipped | Built |
22
22
  | Generated API + component MDX | Restored from cache when `api`/`rtk`/`ui` hashes match | Same cache, then full generate on miss |
23
23
  | TypeDoc workspace `tsc` | Once per generate (api+rtk deps share a process) | Same |
24
24
  | Rspack cache (`node_modules/.cache/rspack`) | Restored per `pull_request` vs `push` | Separate production key |
25
25
 
26
- Production still builds every version. Do not rely on `/57.1.0/…` URLs in a
27
- PR deploy preview.
26
+ Do not rely on `/57.1.0/…` URLs in a PR deploy preview.
28
27
 
29
28
  ## Other high-cost jobs
30
29
 
@@ -142,7 +142,7 @@ The `task` view passed to `run`: `{id, name, version, input, state, phase, attem
142
142
  | `Harness.open({registry, runner?, models?, env?, priceMap?, streaming?, testHooks?})` | Checks requirements, rejects a duplicate `name@version` or agent name, ensures collections and indexes exist. `runner` defaults to `new InProcessRunner()`. See [open options](#open-options). |
143
143
  | `start()` | Throws, claiming nothing, when any non-terminal task uses a `name@version` missing from the registry (see [Versioning](#versioning)). Then starts the runner. Throws when already started. The runner recovers expired tasks once it owns execution. |
144
144
  | `stop()` | Stops claiming work, waits (without a time limit) for every phase in flight while still renewing the owner lease, then releases it. No-op when not started. |
145
- | `createTask(definition, input, {requestId?, userId?})` | Inserts a `pending` task, its `ObsTrace`, and its root span in one transaction. Wakes the runner. The definition must be in the registry. |
145
+ | `createTask(definition, input, {requestId?, userId?, trace?, prompts?})` | Inserts a `pending` task, its `ObsTrace`, and its root span in one transaction. Wakes the runner. The definition must be in the registry. See [Trace scope and prompt versions](#trace-scope-and-prompt-versions). |
146
146
  | `resolveInterrupted(id, {action, reason, result?, userId?})` | Resolve an `interrupted` task. See [resolveInterrupted](#resolveinterrupted). |
147
147
  | `abort(id, {reason, userId?})` | Abort a task and every non-terminal task it owns, bottom-up. See [abort](#abort). |
148
148
  | `decideApproval(approvalId, {approved, reason?, userId?})` | Approve or reject a pending approval without checking approvers. See [Approvals](#approvals). |
@@ -158,6 +158,30 @@ create with the same id returns the first task and leaves no extra trace. Reusin
158
158
  `requestId` for a different task name, or with a different `userId` (including none versus
159
159
  some), throws. A soft-deleted task still owns its `requestId`.
160
160
 
161
+ ### Trace scope and prompt versions
162
+
163
+ A task's `ObsTrace` is its run record. `createTask` can label it:
164
+
165
+ ```typescript
166
+ const prompt = await promptRegistry.get({label: "production", name: "intake-summary"});
167
+ await harness.createTask(intakeSummary, {patientId}, {
168
+ prompts: prompt ? [prompt] : [],
169
+ trace: {scope: String(workspaceId), tags: ["nightly"]},
170
+ userId,
171
+ });
172
+ ```
173
+
174
+ | Option | Type | Description |
175
+ | --- | --- | --- |
176
+ | `trace.scope` | `string` | App-defined scope, such as a tenant or workspace id. Stored as `ObsTrace.scope`, indexed with `created` for per-scope metering (sum `LLM` span `usage.costUsd` by scope and month). Blank throws `harness-invalid-request`. |
177
+ | `trace.tags` | `string[]` | App-defined labels, stored once each as `ObsTrace.tags`. A blank tag throws `harness-invalid-request`. |
178
+ | `prompts` | `{name, version, label?}[]` | Prompt versions the task uses, stored once each on `ObsTrace.prompts`. A `PromptVersionRef` from `promptRegistry.get` fits as-is; its `body` is not stored. A ref without a name, with a non-string `label`, or without a positive integer `version` throws `harness-invalid-request`. |
179
+
180
+ Child tasks and subagents write into the root task's trace, so they share its scope and
181
+ tags. `rt.runAgent` adds its own prompt versions to the same trace (see
182
+ [Subagents](#subagents-rtrunagent)). The admin trace list filters by `scope` and by
183
+ prompt.
184
+
161
185
  ### open options
162
186
 
163
187
  | Option | Type | Description |
@@ -306,7 +330,7 @@ where `lease` is `{owner, duration, heartbeat}` (Luxon `Duration`s). With `maxPh
306
330
  | `rt.signal` | `AbortSignal`. Aborts when the task is aborted (at once in this process, within one heartbeat elsewhere), or when this run loses its lease. Pass it to cancellable calls. |
307
331
  | `rt.createTask(definition, input, {background?, key?})` | Create a child task. Returns its id. See [Child tasks and waitForTasks](#child-tasks-and-waitfortasks). |
308
332
  | `rt.waitForTasks(ids, {policy?})` | Return child outcomes once they settle; until then the task waits. See [Child tasks and waitForTasks](#child-tasks-and-waitfortasks). |
309
- | `rt.runAgent(agent, {input, output?, instructions?})` | Run a registered agent as a subagent and return its answer; until it finishes the task waits. See [Subagents (rt.runAgent)](#subagents-rtrunagent). |
333
+ | `rt.runAgent(agent, {input, output?, instructions?, prompts?})` | Run a registered agent as a subagent and return its answer; until it finishes the task waits. See [Subagents (rt.runAgent)](#subagents-rtrunagent). |
310
334
  | `rt.memo(key)` / `rt.memo(key, value)` | Read, or first-write, a durable value scoped to this task. See [Memos](#memos). |
311
335
  | `rt.waitFor(event, {timeout?})` | Return the payload of the next `event` sent to this task, or `undefined` once `timeout` passes; until then the task waits. See [Events, waits, and sleep](#events-waits-and-sleep). |
312
336
  | `rt.sleep(duration)` | Return once `duration` has passed; until then the task waits. See [Events, waits, and sleep](#events-waits-and-sleep). |
@@ -817,7 +841,9 @@ is not offered to sections, `beforeModelRequest`, or `afterTool` (the tool alrea
817
841
  `basePath` defaults to `/harness`; it must start with `/` and not end with `/`.
818
842
  `heartbeatInterval` (default `{seconds: 15}`, must be positive) paces SSE heartbeats.
819
843
  Conversation, task, and approval routes are `modelRouter`s with instance actions, so they
820
- appear in `/openapi.json` (tag `harness`). The two SSE routes are plain Express routes
844
+ appear in `/openapi.json`. Each route and its mutations use that collection's tag
845
+ (`harnessconversations`, `harnesstasks`, `harnessapprovals`), so a generated client
846
+ invalidates the list it just changed. The two SSE routes are plain Express routes
821
847
  (the listed exception to modelRouter actions) and are not in the OpenAPI spec.
822
848
 
823
849
  ### Conversation routes
@@ -840,7 +866,7 @@ appear in `/openapi.json` (tag `harness`). The two SSE routes are plain Express
840
866
 
841
867
  | Method | Path | Permissions | Behavior |
842
868
  | --- | --- | --- | --- |
843
- | GET | `/harness/tasks/:id` | Owner (`userId`) or admin | One task. Others: 403. |
869
+ | GET | `/harness/tasks/:id` | Owner (`userId`) or admin | One task, without `lease` (the fencing token stays on the server). Others: 403. |
844
870
  | POST | `/harness/tasks/:id/abort` | Owner or admin | Body `{reason}` (required, non-blank). Runs `harness.abort` with the caller as `userId`; returns the aborted task. |
845
871
  | POST | `/harness/tasks/:id/resolveInterrupted` | Admin | Body `{action: "retry" \| "abort" \| "complete", reason, result?}`. Runs `harness.resolveInterrupted` with the caller as `userId`; returns the task. |
846
872
  | GET | `/harness/tasks/:id/events` | Owner or admin | SSE for the task and its descendants. See [Event stream (SSE)](#event-stream-sse). |
@@ -1320,7 +1346,8 @@ summarize: {
1320
1346
  | --- | --- | --- | --- |
1321
1347
  | `input` | `unknown` | Yes | The subagent's user message. A string is sent as-is (blank is rejected); anything else as `JSON.stringify(input)`. |
1322
1348
  | `output` | zod schema | No | Structured output. Defaults to the agent's own `output`. |
1323
- | `instructions` | `string` | No | Replaces the agent's instructions for this subagent conversation only. |
1349
+ | `instructions` | `string` or `PromptVersionRef` | No | Replaces the agent's instructions for this subagent conversation only. A registry prompt (`promptRegistry.get`) runs with its `body`, and its `{name, version, label}` is added to the trace's `prompts`. |
1350
+ | `prompts` | `{name, version, label?}[]` | No | More prompt versions this subagent used, added to the trace's `prompts`. Each version is recorded once per trace. An invalid ref, or an `instructions` prompt without a string `body`, fails the phase with a definition error. |
1324
1351
 
1325
1352
  Returns the final assistant text, or, with a schema, the object the schema parsed. The
1326
1353
  generic `rt.runAgent<T>(...)` types the result when the schema comes from the agent.
@@ -1331,6 +1358,7 @@ What one call does:
1331
1358
  (`ownership: {kind: "task", id: <caller>}`, `status: "busy"`, agent snapshot with
1332
1359
  `instructions` and `outputSchema`), its user message (`seq` 1), and the turn task
1333
1360
  (`ownership: {kind: "task", id: <caller>}`) with an `AGENT` span named after the agent.
1361
+ Prompt versions from `instructions` and `prompts` are added to the trace's `prompts`.
1334
1362
  2. Waits with `rt.waitForTasks([turn])`: the caller commits `waiting` and the phase stops.
1335
1363
  3. When the turn settles the caller wakes and the phase re-runs from its checkpoint. The
1336
1364
  same call finds the same turn and conversation (step 1 writes nothing) and returns.
@@ -91,7 +91,7 @@ src/
91
91
  - **Plugins:** `AiApp`, `LangfuseApp`
92
92
  - **Service:** `AIService`, `TemperaturePresets`, `FileStorageService`, `MCPService`,
93
93
  `getMCPTools`
94
- - **Models:** `AIRequest`, `GptHistory`, `FileAttachment`, `Project`
94
+ - **Models:** `AIRequest`, `GptHistory`, `FileAttachment`, `Project` (lazy; prefer `getProjectModel()`)
95
95
  - **Routes:** `addGptRoutes`, `addGptHistoryRoutes`, `addAiRequestsExplorerRoutes`, `addFileRoutes`, `addProjectRoutes`, `addMcpRoutes`
96
96
  - **Structured output:** `parseAiJson`, `normalizeLlmJsonTextForStructuredOutput`, re-exported `Output`, `jsonSchema`, `JSONValue`, `FlexibleSchema` from `ai`
97
97
  - **Langfuse:** `initLangfuseClient`, `getLangfuseClient`, `shutdownLangfuseClient`, `compilePrompt`, `createPrompt`, `getPrompt`, `createTelemetryConfig`, `preparePromptForAI`, `initTracing`, `shutdownTracing`, `LangfuseCache`, cache helpers
@@ -249,7 +249,8 @@ Metadata for files stored in GCS.
249
249
 
250
250
  ### Project
251
251
 
252
- GPT project with persistent context and memories.
252
+ GPT project with persistent context and memories. Registered lazily: `getProjectModel()`
253
+ (or any use of the deprecated `Project` export) registers it.
253
254
 
254
255
  | Field | Type | Description |
255
256
  |-------|------|-------------|
@@ -440,6 +441,7 @@ new AiApp({
440
441
  | `maxSteps` | Max tool-calling steps (default 5) |
441
442
  | `titleModelId` | Cheaper model for conversation title generation |
442
443
  | `openApiOptions` | Passed to route OpenAPI builders |
444
+ | `projects` | Default `true`. `false` skips the `/gpt/projects` routes, so `AiApp` never registers the `Project` model |
443
445
 
444
446
  ## LangfuseApp plugin
445
447
 
@@ -508,7 +510,7 @@ server `AIService` when configured, otherwise a request-scoped service built fro
508
510
  | `ObsPrompt` | Named prompt (`name` unique) with `folder` and `tags[]` |
509
511
  | `ObsPromptVersion` | Immutable `vN` body, `variables[]`, schemas, `sensitive` (default false), `config` |
510
512
  | `ObsPromptLabel` | Movable labels; unique `(promptId, label)` |
511
- | `ObsTrace` | Root trace: user, session, status, `errorSummary`, `sensitive`, `prompts[]`, usage |
513
+ | `ObsTrace` | Root trace: user, session, status, `errorSummary`, `sensitive`, `prompts[]`, `scope`, `tags[]`, usage |
512
514
  | `ObsSpan` | Nested span with `kind`, `status`, optional `error`, offsets, usage |
513
515
  | `ObsScore` | Scores on a trace/span; many per trace, **no unique index** |
514
516
  | `ObsEvaluator` | Evaluator: `type` (`human` \| `llm-judge` \| `json-assert`), `target`, `dimensions[]`, `runModes`, `instructions`, `judgePromptName` (judge), `assertion` (json-assert), `confidenceAlertBelow` (default 0.7) |
@@ -560,7 +562,7 @@ When `prompts.primary` is `local`, `ObservabilityApp.register` mounts admin-only
560
562
 
561
563
  | Method | Path | Behavior |
562
564
  | --- | --- | --- |
563
- | GET | `/ai/observability/traces` | Admin list. Query `from`, `to`, `prompt`, `status`, `userId`, `sessionId`, `hasScore`, `sensitive`, `flaggedForDataset`, `page`, `limit`. Body is `{data, page, limit, more, total}` so pagination survives RTK `{data}` unwrap. Each row includes `spanCount` and `scoreCount`. `prompts.length` is the `N prompts` count |
565
+ | GET | `/ai/observability/traces` | Admin list. Query `from`, `to`, `prompt`, `status`, `userId`, `sessionId`, `scope`, `hasScore`, `sensitive`, `flaggedForDataset`, `page`, `limit`. Body is `{data, page, limit, more, total}` so pagination survives RTK `{data}` unwrap. Each row includes `spanCount` and `scoreCount`. `prompts.length` is the `N prompts` count |
564
566
  | GET | `/ai/observability/traces/:id` | Span tree (kind, offsets, durations, I/O, cost) plus scores. `errorSummary` is the first span with `status: "error"` |
565
567
  | POST | `/ai/observability/traces/:id/scores` | Persist a score and fan out to every `ScoreSink` |
566
568
  | POST | `/ai/observability/traces/test-multi-stage` | Admin-only smoke workflow, registered only with the local trace sink. Uses `ObservabilityApp.aiService`, or `requestAiServiceFactory({apiKey})` when the server service is absent (`apiKey` comes from `x-ai-api-key`); answers **503** with the same missing-key title as playground when neither exists. Body `{input?: string}` (defaults to a built-in sample). Runs two `AIService.generateJsonObject` calls with `skipTrace: true` and named JSON output schemas (`obs-test-multi-stage-call-1` / `call-2`), a deterministic local `text-metrics` `TOOL` stage, then a final `generateJsonObject` synthesis against `obs-test-multi-stage-final`; exports exactly one parent trace with ordered child spans `LLM`, `LLM`, `TOOL`, `LLM` under a `CHAIN` root. LLM span input includes `outputSchema`. Returns `{traceId, output, stages[]}` where `output` is the final schema object (`sentence`, `phrase`, `keywords`, `metrics`). Child LLM failures export an error trace then rethrow |
@@ -638,6 +640,33 @@ Low-level exports (also used by `addGptRoutes` when `langfuseSystemPromptName` i
638
640
 
639
641
  Subpath imports for tree-shaking: `@terreno/ai/langfuseClient`, `@terreno/ai/langfuseApp`.
640
642
 
643
+ ## Subpath exports
644
+
645
+ Import these instead of `dist/` paths. Deep `dist/` imports break when a bundler (such as
646
+ Vercel's) inlines them and their bare `@terreno/api` imports no longer resolve. Each
647
+ subpath shares module instances with `@terreno/ai/harness`.
648
+
649
+ | Subpath | Exports |
650
+ | --- | --- |
651
+ | `@terreno/ai/harness` | Harness public API; see [AI harness reference](ai-harness.md) |
652
+ | `@terreno/ai/harness/jobsRunner` | `JobsRunner` |
653
+ | `@terreno/ai/harness/agentLoop` | `createAgentTasks` and the agent turn internals |
654
+ | `@terreno/ai/harness/commit` | `createTaskRecords`, `HarnessModels`, and other commit helpers, for custom commits that write domain records inside a task's fenced commit |
655
+ | `@terreno/ai/harness/events` | `insertMessages`, `resolveStreamingOptions` |
656
+ | `@terreno/ai/harness/internalRuntime` | `internalRuntime` |
657
+ | `@terreno/ai/admin` | `AIAdminApp` |
658
+ | `@terreno/ai/observability/observabilityApp` | `ObservabilityApp` |
659
+ | `@terreno/ai/observability/localPlugin` | `createLocalObservabilityPlugin`, `createLocalObservabilityBundle` |
660
+ | `@terreno/ai/observability/promptStore` | `LocalPromptStore` |
661
+
662
+ The internals subpaths follow the harness's own changes; they carry no compatibility
663
+ promise beyond the release they ship in.
664
+
665
+ The root `@terreno/ai` entry registers the `Project` model on first use
666
+ (`getProjectModel()` or the `Project` export), not on import. `AiApp` uses it for
667
+ `/gpt/projects` by default; an app that owns a model named `Project` passes
668
+ `new AiApp({projects: false})` and does not call `addProjectRoutes`.
669
+
641
670
  ## FileStorageService
642
671
 
643
672
  Google Cloud Storage helper for uploads referenced by `addFileRoutes`.
@@ -725,8 +725,16 @@ Master-detail layout. Pass `listViewData` plus `renderListViewItem` for the list
725
725
  screens the detail replaces the list until the user goes back.
726
726
 
727
727
  `desktopChildrenMinWidth` opts into a minimum pixel width for each desktop child when there
728
- are one or two children. `childColumnRounding` sets the border radius on each detail child
729
- column container (defaults to `md`, 4px); the list column is excluded. `narrowViewportChildLabels`
728
+ are one or two children. `childColumnRounding` sets the border radius on each desktop detail
729
+ pane (defaults to `md`, 4px); the list column is excluded. The pane clips to that radius. The
730
+ column inside it scrolls: content taller than the pane scrolls in the column, and a child with
731
+ `height: "100%"` fills the visible pane so a scroll view inside that child scrolls on its own.
732
+ On a narrow web viewport the pager page is height-bounded to that same visible pane. Its rounded
733
+ corners still clip, and a `height: "100%"` child scrolls inside the page. On the labeled pager,
734
+ previous and next sit in fixed left and right slots in a row under the column. A missing direction
735
+ leaves that slot empty, so the other arrow stays put. The column height is the measured pane minus
736
+ top spacing, the navigation row, bottom spacing, and `bottomNavBarHeight`.
737
+ `narrowViewportChildLabels`
730
738
  opts into a labeled full-width pager on the narrow viewport. `narrowBelowWidth` uses that
731
739
  viewport when the window is at or below the given pixel width; when omitted, the narrow
732
740
  viewport follows `isNarrowViewport()`. These props are web only; the native `SplitPage`