@terreno/mcp 57.10.1 → 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.
- package/dist/docs/upgrades/57.11.0.md +80 -0
- package/dist/docs/upgrades/57.12.0.md +89 -0
- package/dist/docs/versioned/next/explanation/ai-workflows.md +4 -3
- package/dist/docs/versioned/next/explanation/durable-agent-harness.md +17 -4
- package/dist/docs/versioned/next/explanation/local-first-data.md +1 -0
- package/dist/docs/versioned/next/explanation/roadmap-process.md +4 -2
- package/dist/docs/versioned/next/how-to/README.md +1 -0
- package/dist/docs/versioned/next/how-to/call-external-apis.md +1 -1
- package/dist/docs/versioned/next/how-to/circleci.md +6 -2
- package/dist/docs/versioned/next/how-to/github-actions-ci.md +5 -6
- package/dist/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +31 -1
- package/dist/docs/versioned/next/how-to/preview-demo.md +7 -3
- package/dist/docs/versioned/next/how-to/run-harness-on-multiple-instances.md +93 -0
- package/dist/docs/versioned/next/implementationPlans/durable-agent-harness.md +1 -1
- package/dist/docs/versioned/next/reference/ai-harness.md +138 -17
- package/dist/docs/versioned/next/reference/ai.md +33 -4
- package/dist/docs/versioned/next/reference/api.md +64 -2
- package/dist/docs/versioned/next/reference/environment-variables.md +1 -1
- package/dist/docs/versioned/next/reference/lifecycle-plugin.md +11 -0
- package/dist/docs/versioned/next/reference/syncdb.md +156 -6
- package/dist/docs/versioned/next/reference/ui.md +14 -5
- package/dist/docs/versioned/next/tasks/durable-agent-harness.md +4 -2
- package/package.json +3 -3
- package/src/docs/upgrades/57.11.0.md +80 -0
- package/src/docs/upgrades/57.12.0.md +89 -0
- package/src/docs/versioned/next/explanation/ai-workflows.md +4 -3
- package/src/docs/versioned/next/explanation/durable-agent-harness.md +17 -4
- package/src/docs/versioned/next/explanation/local-first-data.md +1 -0
- package/src/docs/versioned/next/explanation/roadmap-process.md +4 -2
- package/src/docs/versioned/next/how-to/README.md +1 -0
- package/src/docs/versioned/next/how-to/call-external-apis.md +1 -1
- package/src/docs/versioned/next/how-to/circleci.md +6 -2
- package/src/docs/versioned/next/how-to/github-actions-ci.md +5 -6
- package/src/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +31 -1
- package/src/docs/versioned/next/how-to/preview-demo.md +7 -3
- package/src/docs/versioned/next/how-to/run-harness-on-multiple-instances.md +93 -0
- package/src/docs/versioned/next/implementationPlans/durable-agent-harness.md +1 -1
- package/src/docs/versioned/next/reference/ai-harness.md +138 -17
- package/src/docs/versioned/next/reference/ai.md +33 -4
- package/src/docs/versioned/next/reference/api.md +64 -2
- package/src/docs/versioned/next/reference/environment-variables.md +1 -1
- package/src/docs/versioned/next/reference/lifecycle-plugin.md +11 -0
- package/src/docs/versioned/next/reference/syncdb.md +156 -6
- package/src/docs/versioned/next/reference/ui.md +14 -5
- package/src/docs/versioned/next/tasks/durable-agent-harness.md +4 -2
|
@@ -141,8 +141,8 @@ The `task` view passed to `run`: `{id, name, version, input, state, phase, attem
|
|
|
141
141
|
| --- | --- |
|
|
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
|
-
| `stop()` | Stops claiming work, waits (without a time limit) for
|
|
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. |
|
|
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?, 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 |
|
|
@@ -166,7 +190,7 @@ some), throws. A soft-deleted task still owns its `requestId`.
|
|
|
166
190
|
| `models` | `({provider, modelId}) => LanguageModel` | Resolves an agent's model refs to Vercel AI SDK models. Required when `registry` lists an agent. |
|
|
167
191
|
| `env` | `ExecutionEnv` | Handed to phases as `rt.env` and to tools as `api.env`. Optional. |
|
|
168
192
|
| `priceMap` | `Record<modelId, {inputPerMTok, outputPerMTok}>` | Prices LLM spans (`usage.costUsd`). Defaults to the registered `ObservabilityApp`'s `priceMap`, read at call time. |
|
|
169
|
-
| `runner` | `HarnessRunner` | Default `new InProcessRunner()`. |
|
|
193
|
+
| `runner` | `HarnessRunner` | Default `new InProcessRunner()`. For many instances, `JobsRunner` (see [JobsRunner](#jobsrunner)). |
|
|
170
194
|
| `streaming` | `{deltaFlushChars?, deltaFlushInterval?, deltaTtl?}` | How streamed model text becomes `delta` events. Defaults: 200 characters, `{milliseconds: 250}`, `{hours: 1}`. Non-positive values throw. See [Event stream (SSE)](#event-stream-sse). |
|
|
171
195
|
| `testHooks` | `HarnessTestHooks` | Test-only. See [Testing](#testing). |
|
|
172
196
|
|
|
@@ -183,7 +207,8 @@ const harness = await Harness.open({
|
|
|
183
207
|
|
|
184
208
|
| Option | Default | Description |
|
|
185
209
|
| --- | --- | --- |
|
|
186
|
-
| `
|
|
210
|
+
| `concurrency` | `8` | Most claimed tasks run at once. Must be a positive integer, or the constructor throws. `1` runs one task at a time. |
|
|
211
|
+
| `pollInterval` | `{milliseconds: 250}` | Idle sleep between claim attempts. `createTask` and a task that frees a slot wake the runner early. |
|
|
187
212
|
| `leaseDuration` | `{seconds: 30}` | Lifetime of the owner lease and of each task lease without a renewal. Luxon `DurationLike`. |
|
|
188
213
|
| `heartbeatInterval` | `{seconds: 10}` | How often both leases are renewed. Must be positive and shorter than `leaseDuration`, or the constructor throws. |
|
|
189
214
|
| `ownerId` | `hostname:pid:uuid` | Id written into every lease this runner holds. |
|
|
@@ -197,13 +222,101 @@ Only the owner claims work. It claims the oldest `pending` task whose `name@vers
|
|
|
197
222
|
registered, whose `runAt` is empty or past (by Luxon's `DateTime.now()`), and that has no
|
|
198
223
|
`abortRequested`. It sets the task `running` with a fresh task lease and runs phases one
|
|
199
224
|
after another until the task stops: terminal, `pending` for a retry, `waiting`, or
|
|
200
|
-
aborted.
|
|
201
|
-
|
|
225
|
+
aborted. An idle owner re-polls every `pollInterval`, so a retry starts within one poll of
|
|
226
|
+
its `runAt`.
|
|
227
|
+
|
|
228
|
+
Up to `concurrency` tasks run at once, each under its own task lease and heartbeat, so a
|
|
229
|
+
slow phase (a long model call, a phase blocked on I/O) does not delay other tasks. Ordering
|
|
230
|
+
guarantees:
|
|
231
|
+
|
|
232
|
+
- Tasks are **claimed** oldest first (`created`), one claim at a time, while a slot is free.
|
|
233
|
+
- With `concurrency` above 1, claimed tasks run in parallel, so a later task can finish
|
|
234
|
+
first. Use `concurrency: 1` for strict one-at-a-time order.
|
|
235
|
+
- Phases of one task always run in order, and a phase never commits twice: the claim is an
|
|
236
|
+
atomic update, and every commit is fenced on the task lease. (A runner that froze past
|
|
237
|
+
its lease may still be executing phase code while recovery replays it; only its commit
|
|
238
|
+
is rejected.)
|
|
239
|
+
- Turns of one conversation stay serial regardless of `concurrency`; turns of different
|
|
240
|
+
conversations, and sibling child tasks, run in parallel.
|
|
241
|
+
- `stop()` stops claiming and waits for every task in flight.
|
|
242
|
+
|
|
243
|
+
Before Phase 2 the runner ran one task at a time. Upgrading to the default of 8 means
|
|
244
|
+
parallel model calls (watch provider rate limits and cost) and more stream-counter retries
|
|
245
|
+
(see [Implementation notes](#implementation-notes)). Pass `concurrency: 1` to keep the old
|
|
246
|
+
behavior.
|
|
247
|
+
|
|
248
|
+
### JobsRunner
|
|
249
|
+
|
|
250
|
+
Runs phases as `@terreno/jobs` jobs so any number of instances share the work. Import it
|
|
251
|
+
from its own entry point; `@terreno/jobs` is an optional peer dependency, and importing
|
|
252
|
+
`@terreno/ai` or `@terreno/ai/harness` never loads it. How-to:
|
|
253
|
+
[Run the harness on multiple instances](../how-to/run-harness-on-multiple-instances.md).
|
|
254
|
+
|
|
255
|
+
```typescript
|
|
256
|
+
import {JobsApp} from "@terreno/jobs";
|
|
257
|
+
import {Harness} from "@terreno/ai/harness";
|
|
258
|
+
import {JobsRunner} from "@terreno/ai/harness/jobsRunner";
|
|
259
|
+
|
|
260
|
+
const jobs = new JobsApp();
|
|
261
|
+
const harness = await Harness.open({registry, runner: new JobsRunner({jobs})});
|
|
262
|
+
app.register(jobs).build(); // the runner enqueues through the registered jobs service
|
|
263
|
+
await harness.start();
|
|
264
|
+
await jobs.startWorker();
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
| Option | Default | Description |
|
|
268
|
+
| --- | --- | --- |
|
|
269
|
+
| `jobs` | required | The `JobsApp`. The constructor defines `terreno.harness.phase` on it (`HARNESS_PHASE_JOB_NAME`). Register it and build the app before `harness.start()`; otherwise `start()` throws `harness-config-invalid`. |
|
|
270
|
+
| `pollInterval` | `{milliseconds: 500}` | Sleep between dispatch scans. `createTask`, `sendEvent`, and each finished phase wake the dispatcher early. |
|
|
271
|
+
| `leaseDuration` | `{seconds: 30}` | Lifetime of each task lease without a renewal. |
|
|
272
|
+
| `heartbeatInterval` | `{seconds: 10}` | How often a running phase renews its task lease, and how often this instance sweeps expired leases. Must be positive and shorter than `leaseDuration`, or the constructor throws. |
|
|
273
|
+
| `ownerId` | `hostname:pid:uuid` | Id written into every task lease this runner holds. |
|
|
274
|
+
| `dispatchBatchSize` | `100` | Most runnable tasks one scan enqueues. Must be a positive integer, or the constructor throws. |
|
|
275
|
+
|
|
276
|
+
Every instance runs a dispatcher and a jobs worker. There is no owner lease.
|
|
277
|
+
|
|
278
|
+
1. **Dispatch.** Each scan lists runnable tasks (the same rule `InProcessRunner` claims by)
|
|
279
|
+
and enqueues one `terreno.harness.phase` job per task with payload `{taskId}` and
|
|
280
|
+
idempotency key `taskId:phase:attempt:claims`. `claims` counts how often the task was
|
|
281
|
+
claimed, so each runnable visit of a phase (first run, retry, event wake, replay) gets
|
|
282
|
+
its own job, and repeated scans or instances enqueue it once. When the job with that key
|
|
283
|
+
already ended without claiming the task (for example it died after its retries, or ran
|
|
284
|
+
on an instance mid-deploy that did not register the task), the dispatcher follows or
|
|
285
|
+
extends the chain `<key>:<endedJobId>` until a job is in flight, so the task is not
|
|
286
|
+
stranded.
|
|
287
|
+
2. **Run one phase.** The job handler claims that task under a fresh fenced task lease,
|
|
288
|
+
runs **one** phase with heartbeats, and hands a task that is still `running` back as
|
|
289
|
+
`pending` (lease cleared) for the next job. It then wakes the dispatcher. A handler
|
|
290
|
+
whose task is no longer runnable (another job claimed it, or it is aborting) does
|
|
291
|
+
nothing and succeeds.
|
|
292
|
+
3. **Recover.** Each instance sweeps expired task leases once per `heartbeatInterval`, with
|
|
293
|
+
the same replay and interruption rules as `InProcessRunner`. A replayed task becomes
|
|
294
|
+
runnable with a new `claims` value, so it is dispatched again.
|
|
295
|
+
|
|
296
|
+
Load: with N instances, the recovery sweep (expired leases, settled child waits, stranded
|
|
297
|
+
queued conversations) runs N times per `heartbeatInterval`, where `InProcessRunner` runs it
|
|
298
|
+
once, and each scan does one job lookup per runnable task (up to `dispatchBatchSize`). The
|
|
299
|
+
sweeps are fenced, so this costs queries, not correctness. Raise `heartbeatInterval` and
|
|
300
|
+
`pollInterval` for large fleets.
|
|
301
|
+
|
|
302
|
+
The task lease, not the job lock, is the authority. Two jobs for one phase race on the
|
|
303
|
+
atomic claim, and only one runs it. A worker that froze past its lease cannot commit: its
|
|
304
|
+
`rt.commit` throws `HarnessCommitConflictError`. A phase that runs longer than the jobs
|
|
305
|
+
lock TTL is safe for the same reason. A job that runs on an instance whose harness is not
|
|
306
|
+
started (or is stopping) throws `harness-runner-stopped`, so the jobs worker retries it.
|
|
307
|
+
|
|
308
|
+
`stop()` stops dispatching and waits for every phase this instance is running. Stop the
|
|
309
|
+
jobs worker (`jobs.stopWorker()`) after `harness.stop()`.
|
|
310
|
+
|
|
311
|
+
### Custom runners
|
|
202
312
|
|
|
203
313
|
A custom runner implements `HarnessRunner` (`start(context)`, `stop()`, `wake()`). The
|
|
204
314
|
`context` provides `acquireOwnerLease(lease)`, `releaseOwnerLease(lease)`,
|
|
205
|
-
`recoverExpired()`, `claimNext(lease)`,
|
|
206
|
-
|
|
315
|
+
`recoverExpired()`, `claimNext(lease)`, `claimTask(taskId, lease)` (claim one task by id
|
|
316
|
+
when it is runnable now, else `null`), `listRunnable(limit)` (oldest runnable tasks as
|
|
317
|
+
`{taskId, phase, attempt, claims}`, unclaimed), and `runTask(task, lease, {maxPhases?})`,
|
|
318
|
+
where `lease` is `{owner, duration, heartbeat}` (Luxon `Duration`s). With `maxPhases`,
|
|
319
|
+
`runTask` hands a task that is still `running` after that many phases back as `pending`.
|
|
207
320
|
|
|
208
321
|
## Runtime (rt)
|
|
209
322
|
|
|
@@ -217,7 +330,7 @@ A custom runner implements `HarnessRunner` (`start(context)`, `stop()`, `wake()`
|
|
|
217
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. |
|
|
218
331
|
| `rt.createTask(definition, input, {background?, key?})` | Create a child task. Returns its id. See [Child tasks and waitForTasks](#child-tasks-and-waitfortasks). |
|
|
219
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). |
|
|
220
|
-
| `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). |
|
|
221
334
|
| `rt.memo(key)` / `rt.memo(key, value)` | Read, or first-write, a durable value scoped to this task. See [Memos](#memos). |
|
|
222
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). |
|
|
223
336
|
| `rt.sleep(duration)` | Return once `duration` has passed; until then the task waits. See [Events, waits, and sleep](#events-waits-and-sleep). |
|
|
@@ -261,7 +374,9 @@ Assumptions:
|
|
|
261
374
|
recovery treats them as expired.
|
|
262
375
|
|
|
263
376
|
Recovery runs when a runner becomes owner (on `start()` or on takeover) and on every owner
|
|
264
|
-
heartbeat.
|
|
377
|
+
heartbeat. `JobsRunner` has no owner lease: every instance sweeps once per
|
|
378
|
+
`heartbeatInterval`, and concurrent sweeps are fenced, so each expired task is recovered
|
|
379
|
+
once. It scans up to 100 `running` tasks whose lease expired (or that have no lease),
|
|
265
380
|
oldest first, for registered `name@version`s only. A stopping owner skips the sweep. A task
|
|
266
381
|
whose interruption commit keeps failing for a non-conflict reason is logged and retried on
|
|
267
382
|
the next sweep.
|
|
@@ -726,7 +841,9 @@ is not offered to sections, `beforeModelRequest`, or `afterTool` (the tool alrea
|
|
|
726
841
|
`basePath` defaults to `/harness`; it must start with `/` and not end with `/`.
|
|
727
842
|
`heartbeatInterval` (default `{seconds: 15}`, must be positive) paces SSE heartbeats.
|
|
728
843
|
Conversation, task, and approval routes are `modelRouter`s with instance actions, so they
|
|
729
|
-
appear in `/openapi.json
|
|
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
|
|
730
847
|
(the listed exception to modelRouter actions) and are not in the OpenAPI spec.
|
|
731
848
|
|
|
732
849
|
### Conversation routes
|
|
@@ -749,7 +866,7 @@ appear in `/openapi.json` (tag `harness`). The two SSE routes are plain Express
|
|
|
749
866
|
|
|
750
867
|
| Method | Path | Permissions | Behavior |
|
|
751
868
|
| --- | --- | --- | --- |
|
|
752
|
-
| 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. |
|
|
753
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. |
|
|
754
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. |
|
|
755
872
|
| GET | `/harness/tasks/:id/events` | Owner or admin | SSE for the task and its descendants. See [Event stream (SSE)](#event-stream-sse). |
|
|
@@ -1229,7 +1346,8 @@ summarize: {
|
|
|
1229
1346
|
| --- | --- | --- | --- |
|
|
1230
1347
|
| `input` | `unknown` | Yes | The subagent's user message. A string is sent as-is (blank is rejected); anything else as `JSON.stringify(input)`. |
|
|
1231
1348
|
| `output` | zod schema | No | Structured output. Defaults to the agent's own `output`. |
|
|
1232
|
-
| `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. |
|
|
1233
1351
|
|
|
1234
1352
|
Returns the final assistant text, or, with a schema, the object the schema parsed. The
|
|
1235
1353
|
generic `rt.runAgent<T>(...)` types the result when the schema comes from the agent.
|
|
@@ -1240,6 +1358,7 @@ What one call does:
|
|
|
1240
1358
|
(`ownership: {kind: "task", id: <caller>}`, `status: "busy"`, agent snapshot with
|
|
1241
1359
|
`instructions` and `outputSchema`), its user message (`seq` 1), and the turn task
|
|
1242
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`.
|
|
1243
1362
|
2. Waits with `rt.waitForTasks([turn])`: the caller commits `waiting` and the phase stops.
|
|
1244
1363
|
3. When the turn settles the caller wakes and the phase re-runs from its checkpoint. The
|
|
1245
1364
|
same call finds the same turn and conversation (step 1 writes nothing) and returns.
|
|
@@ -1503,6 +1622,7 @@ empty objects are kept (`minimize: false`), so an initial state `{}` is stored a
|
|
|
1503
1622
|
| `outcome` | `{status, result, error}` | Set on terminal commit. |
|
|
1504
1623
|
| `attempt` | Number | Failed attempts of the current phase. Default 0. |
|
|
1505
1624
|
| `step` | Number | Phase commits so far; names the current phase visit for idempotent child creation. Default 0. |
|
|
1625
|
+
| `claims` | Number | Times a runner claimed the task. Numbers each runnable visit; `JobsRunner` puts it in the job idempotency key. Default 0; a missing value reads as 0. |
|
|
1506
1626
|
| `retry` | `{maxAttempts, backoffMs, maxBackoffMs}` | Copied from the definition. |
|
|
1507
1627
|
| `ownership` | `{kind: root \| task \| conversation, id}` | Default `root`. `rt.createTask` children are `{kind: "task", id: <parent>}`; agent turns are `{kind: "conversation", id}`, except subagent turns, which are `{kind: "task", id: <caller>}`. |
|
|
1508
1628
|
| `rootTaskId` | ObjectId | Top of the ownership tree; equals `_id` for root tasks. |
|
|
@@ -1686,12 +1806,13 @@ lists every kind:
|
|
|
1686
1806
|
| `code` | `status` | `title` | Kinds of failure |
|
|
1687
1807
|
| --- | --- | --- | --- |
|
|
1688
1808
|
| `harness-definition-invalid` | 500 | Invalid harness definition | `defineTask` / `defineTool` / `defineAgent` / `defineExtension` / `approvalGate` validation, and `rt.*` misuse inside a phase. Thrown as `HarnessDefinitionError`, which fails the task at once (no retry). |
|
|
1689
|
-
| `harness-config-invalid` | 500 | Invalid harness configuration | `Harness.open`, `HarnessApp`, `InProcessRunner`, `streaming`, and registry problems. |
|
|
1809
|
+
| `harness-config-invalid` | 500 | Invalid harness configuration | `Harness.open`, `HarnessApp`, `InProcessRunner` (lease timing, `concurrency`), `JobsRunner`, `streaming`, and registry problems. |
|
|
1690
1810
|
| `harness-replica-set-required` | 500 | MongoDB replica set required | `Harness.open` on a deployment without transactions. |
|
|
1691
|
-
| `harness-invalid-request` | 400 | Invalid harness request | A caller passed a bad argument (`abort`, `sendEvent`, `decideApproval`, `resolveInterrupted`, `submit` / `send`, `createConversation`, memos). |
|
|
1811
|
+
| `harness-invalid-request` | 400 | Invalid harness request | A caller passed a bad argument (`abort`, `sendEvent`, `decideApproval`, `resolveInterrupted`, `submit` / `send`, `createConversation`, memos, a `terreno.harness.phase` payload without `taskId`). |
|
|
1692
1812
|
| `harness-not-registered` | 404 | Not registered in this harness | A task definition, agent, or extension the registry lacks. |
|
|
1693
1813
|
| `harness-not-found` | 404 | Harness record not found | A conversation or child task disappeared. |
|
|
1694
|
-
| `harness-already-started` | 409 | Already started | `Harness.start` / `InProcessRunner.start` called twice. |
|
|
1814
|
+
| `harness-already-started` | 409 | Already started | `Harness.start` / `InProcessRunner.start` / `JobsRunner.start` called twice. |
|
|
1815
|
+
| `harness-runner-stopped` | 503 | Harness runner is not running | A `terreno.harness.phase` job ran on an instance whose harness is not started; the jobs worker retries it. |
|
|
1695
1816
|
| `harness-commit-conflict` | 409 | Harness commit lost its checkpoint fence | `HarnessCommitConflictError`. |
|
|
1696
1817
|
| `harness-approval-not-pending` | 409 | Approval can no longer be decided | `HarnessApprovalConflictError`. |
|
|
1697
1818
|
| `harness-conversation-busy` | 409 | Conversation is busy | `HarnessConversationBusyError`. |
|
|
@@ -1809,7 +1930,7 @@ Low-risk choices made in the first slice:
|
|
|
1809
1930
|
| The SSE tail starts at a cluster time read before any subscriber's replay query | No gap between replay and tail; overlap is removed by `seq`. |
|
|
1810
1931
|
| One shared change stream per `HarnessApp`, not one per connection | A change stream holds a pooled connection while it waits; per-connection streams let a few dozen viewers starve commits and lease renewals. |
|
|
1811
1932
|
| Queue recovery is a sweep by the execution owner, not part of the turn's terminal commit | Starting a turn is its own transaction; the sweep (fenced, idempotent) covers the crash window between the two. |
|
|
1812
|
-
| Writers to one stream conflict on its counter document | The price of commit-ordered `seq`s. `withTransaction` retries the loser. `InProcessRunner`
|
|
1933
|
+
| Writers to one stream conflict on its counter document | The price of commit-ordered `seq`s. `withTransaction` retries the loser. `InProcessRunner` (default `concurrency: 8`) and `JobsRunner` both run sibling tasks of one tree in parallel, so their writes to the shared root stream retry alongside HTTP submits, approvals, and deltas. `InProcessRunner({concurrency: 1})` avoids task-on-task contention. |
|
|
1813
1934
|
| Interruption spans start at `lease.acquiredAt` | Records when the cut-off phase began. |
|
|
1814
1935
|
| `resolveInterrupted({action: "abort"})` closes the trace as `error` | `ObsTrace.status` is `ok` or `error`; an abort is not a success. |
|
|
1815
1936
|
| `requestId` replays are scoped to the same `userId` | Stops one caller from reading another user's task through a shared key. |
|
|
@@ -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`.
|
|
@@ -41,7 +41,7 @@ REST API framework built on Express and Mongoose. Provides modelRouter (CRUD end
|
|
|
41
41
|
- Validation: `configureOpenApiValidator`, `validateRequestBody`, `validateQueryParams`, `createValidator`
|
|
42
42
|
- Middleware: `openApiEtagMiddleware`, `sentryAppVersionMiddleware`
|
|
43
43
|
- Extensibility: `TerrenoPlugin` interface
|
|
44
|
-
- Notifiers: `sendToSlack`, `formatSlackUserMention`, `lookupSlackUserIdByEmail`, `sendToGoogleChat`, `sendToZoom`
|
|
44
|
+
- Notifiers: `sendToSlack`, `formatSlackUserMention`, `lookupSlackUserIdByEmail`, `createSlackPrivateChannel`, `inviteSlackUsersToChannel`, `postSlackMessage`, `findSlackChannelByName`, `sendToGoogleChat`, `sendToZoom`
|
|
45
45
|
- HTTP client: `createAuthenticatedClient`, `withApiErrorHandling`, `normalizeApiError`, `markRetryUnsafe`
|
|
46
46
|
|
|
47
47
|
## Server Setup
|
|
@@ -224,6 +224,30 @@ export const todoRouter = modelRouter("/todos", Todo, {
|
|
|
224
224
|
Do not add `endpoints: (router) => { router.get(...) }` when an action fits.
|
|
225
225
|
Action config supports `allowAnonymous: true` as a per-action override; it does not change authentication for CRUD or sibling actions.
|
|
226
226
|
|
|
227
|
+
## modelRouter list queries
|
|
228
|
+
|
|
229
|
+
`GET /{path}` accepts filters on fields listed in `queryFields` (qs syntax, so operators work:
|
|
230
|
+
`?status[$in][0]=open&created[$gte]=2026-01-01`), plus the reserved `limit`, `page`, and `sort`.
|
|
231
|
+
`limit` is clamped to `maxLimit` (default 500); the response echoes the applied `limit` with
|
|
232
|
+
`{data, limit, more, page, total}`.
|
|
233
|
+
|
|
234
|
+
A filter on a field missing from `queryFields` is rejected before query validation runs, so
|
|
235
|
+
it can never be stripped and silently ignored:
|
|
236
|
+
|
|
237
|
+
```json
|
|
238
|
+
{
|
|
239
|
+
"status": 400,
|
|
240
|
+
"code": "query-param-not-allowed",
|
|
241
|
+
"title": "Query parameter not allowed",
|
|
242
|
+
"detail": "title is not allowed as a query param. Add \"title\" to queryFields on the Todo modelRouter to filter on it. Allowed: completed, created, ownerId.",
|
|
243
|
+
"source": {"parameter": "title"},
|
|
244
|
+
"meta": {"queryParam": "title", "model": "Todo", "allowedQueryFields": ["completed", "created", "ownerId"]}
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
`@terreno/syncdb` raises this as `QueryFieldNotAllowedError` (see
|
|
249
|
+
[Query windows](syncdb.md#query-windows)).
|
|
250
|
+
|
|
227
251
|
## modelRouter array operations
|
|
228
252
|
|
|
229
253
|
When a model has at least one array field, `modelRouter` provides subroutes for adding,
|
|
@@ -1581,6 +1605,44 @@ await sendToSlack(`${formatSlackUserMention("U012ABCDEF")} Deployment complete`,
|
|
|
1581
1605
|
});
|
|
1582
1606
|
``````
|
|
1583
1607
|
|
|
1608
|
+
Incoming webhooks are fixed to the channel chosen when the webhook was created.
|
|
1609
|
+
Private channels, invites, and messages to a channel id use the bot token for
|
|
1610
|
+
one Slack workspace (`SLACK_BOT_TOKEN`):
|
|
1611
|
+
|
|
1612
|
+
| Function | Slack method | Bot scopes |
|
|
1613
|
+
| --- | --- | --- |
|
|
1614
|
+
| `createSlackPrivateChannel` | `conversations.create`, `conversations.invite`, `chat.postMessage` | `groups:write`, `groups:write.invites`, `chat:write` |
|
|
1615
|
+
| `inviteSlackUsersToChannel` | `conversations.invite` | `groups:write.invites` for private channels, `channels:write.invites` for public |
|
|
1616
|
+
| `postSlackMessage` | `chat.postMessage` | `chat:write` |
|
|
1617
|
+
| `findSlackChannelByName` | `conversations.list` | `channels:read`, `groups:read` |
|
|
1618
|
+
| `lookupSlackUserIdByEmail` | `users.lookupByEmail` | `users:read.email` |
|
|
1619
|
+
|
|
1620
|
+
The bot can post and invite only in channels it belongs to. Creating a private
|
|
1621
|
+
channel makes the bot a member. An existing private channel must already
|
|
1622
|
+
include the app; a bot cannot join a private channel by itself. Store the
|
|
1623
|
+
returned channel id. Names are unique, mutable, and limited to lowercase
|
|
1624
|
+
letters, numbers, hyphens, and underscores.
|
|
1625
|
+
|
|
1626
|
+
``````typescript
|
|
1627
|
+
import {createSlackPrivateChannel, inviteSlackUsersToChannel} from "@terreno/api";
|
|
1628
|
+
|
|
1629
|
+
const channel = await createSlackPrivateChannel({
|
|
1630
|
+
name: "case-12345",
|
|
1631
|
+
userIds: ["U012ABCDEF"],
|
|
1632
|
+
initialMessage: "Case opened.",
|
|
1633
|
+
mentionUserIds: ["U012ABCDEF"],
|
|
1634
|
+
});
|
|
1635
|
+
|
|
1636
|
+
await inviteSlackUsersToChannel({
|
|
1637
|
+
channelId: channel.channelId,
|
|
1638
|
+
userIds: ["U098765432"],
|
|
1639
|
+
});
|
|
1640
|
+
``````
|
|
1641
|
+
|
|
1642
|
+
A taken name throws status 409. `findSlackChannelByName({name})` returns a
|
|
1643
|
+
public channel, or a private channel the bot is already in. It returns
|
|
1644
|
+
`undefined` when that channel is not visible to the bot.
|
|
1645
|
+
|
|
1584
1646
|
### Google Chat Notifications
|
|
1585
1647
|
|
|
1586
1648
|
``````typescript
|
|
@@ -1913,7 +1975,7 @@ Complete reference of environment variables used by @terreno/api:
|
|
|
1913
1975
|
| Variable | Required | Default | Description |
|
|
1914
1976
|
|----------|----------|---------|-------------|
|
|
1915
1977
|
| `SLACK_WEBHOOKS` | No | — | JSON object mapping names to Slack webhook URLs: `{"default":"https://..."}` |
|
|
1916
|
-
| `SLACK_BOT_TOKEN` | No | — | Bot token (`xoxb-…`)
|
|
1978
|
+
| `SLACK_BOT_TOKEN` | No | — | Bot token (`xoxb-…`) for one workspace. Scopes: `users:read.email`, `groups:write`, `groups:write.invites`, `channels:write.invites`, `chat:write`, `channels:read`, `groups:read` |
|
|
1917
1979
|
| `GOOGLE_CHAT_WEBHOOKS` | No | — | JSON object mapping names to Google Chat webhook URLs |
|
|
1918
1980
|
| `ZOOM_CHAT_WEBHOOKS` | No | — | JSON object mapping names to Zoom webhook URLs |
|
|
1919
1981
|
| `WEBHOOK_SECRET` | No | — | Secret for validating incoming webhook signatures |
|
|
@@ -223,7 +223,7 @@ There is **no** `RATE_LIMIT_ENABLED` (or similar) read by `@terreno/api`. Apps t
|
|
|
223
223
|
|----------|---------|----------|---------|--------|-------|
|
|
224
224
|
| `SLACK_WEBHOOKS` | `@terreno/api` | ❌ | — | Yes | server |
|
|
225
225
|
| `SLACK_WEBHOOK_URL` | scripts | ❌ | — | Yes | server |
|
|
226
|
-
| `SLACK_BOT_TOKEN` | `@terreno/api` `lookupSlackUserIdByEmail` | ❌ | — | Yes | server |
|
|
226
|
+
| `SLACK_BOT_TOKEN` | `@terreno/api` Slack Web API (`lookupSlackUserIdByEmail`, private channels, invites, `chat.postMessage`) | ❌ | — | Yes | server |
|
|
227
227
|
| `GOOGLE_CHAT_WEBHOOKS` | `@terreno/api` | ❌ | — | Yes | server |
|
|
228
228
|
| `GOOGLE_CHAT_WEBHOOK_URL` | scripts | ❌ | — | Yes | server |
|
|
229
229
|
| `ZOOM_CHAT_WEBHOOKS` | `@terreno/api` | ❌ | — | Yes | server |
|
|
@@ -16,6 +16,17 @@ loops that invoke those stages; they are not stages and must not appear as `stag
|
|
|
16
16
|
| `terreno-4-brew` | Roast PASS for every in-scope task + branch/evidence | pushed head + PR + product-CI trigger check + review-bot wait + attached evidence | Taste |
|
|
17
17
|
| `terreno-5-taste` | PR + current state | one current-head reaction; before push: fetch latest `master` (merge only when needed), re-verify last-run failed tests locally, run root `prepush` when present (otherwise affected-package checks) in a no-context subagent, then watch CI; invoked directly, repeats bounded reactions until terminal | null or fresh Taste |
|
|
18
18
|
|
|
19
|
+
Pre-Grow skill (not a stage):
|
|
20
|
+
|
|
21
|
+
| Skill | Input | Primary output | Next |
|
|
22
|
+
| --- | --- | --- | --- |
|
|
23
|
+
| `terreno-prd` | app owner + conversation, prototype, repository | interview, then a published full product PRD (problem, why now, solution, user stories, success metrics and guardrails, constraints, rollout, out of scope); every metric measurable or `TBD` with its decider | Grow, with the PRD as its spec; metrics become acceptance criteria, `TBD`s become grilling questions, settled product decisions are not re-asked |
|
|
24
|
+
|
|
25
|
+
It is explicit-invoke only (`/terreno-prd`, `$terreno-prd`, `/terreno:prd`). It
|
|
26
|
+
interviews with Grow's grilling procedure over a product tree, is sized for one app owner
|
|
27
|
+
and one or two developers, and carries no implementation or testing decisions; those stay
|
|
28
|
+
in Grow.
|
|
29
|
+
|
|
19
30
|
Outer loops (not stages):
|
|
20
31
|
|
|
21
32
|
| Skill | What it walks | Default |
|