@fourier-labs/harbour 0.1.46 → 0.1.47

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.
@@ -195,8 +195,8 @@ The person you are working with may not be a developer. They say what they want
195
195
  - "run it", "show me", "let me try it" → start \`harbour dev --app-root .\` in the background (it keeps running; the first start downloads the native runtime and takes a minute or two). Wait for the line \`Harbour dev is running: http://127.0.0.1:<port>\` and give them that link. Do this unasked as soon as the first check is green — they should always have the link. Locally they are a fixture user; no company sign-in is needed.
196
196
  - "check it", "is it ok?", "is it ready?" → with dev running, \`harbour check --app-root . --json\`, then read \`.harbour/local/check-report.json\`. Failures in the app's code are yours to fix — fix, then check again until it is clean. Run the checks yourself after every change and before every ship, without being asked and without offering them as a choice.
197
197
  - "does it work?", and before you report anything as working → open the dev link in your own browser when you have one, press the control you built or changed, and read what the app shows. A green \`harbour check\` is not that proof: it answers governed AI and company systems from fixtures, so the refusals that matter (a field the company's AI route does not accept, a consent the operation does not need, a channel that is not approved) appear only when the control is really pressed. Test rendering, navigation, fixtures and approved reads automatically. In development a Send sends a real email or Slack message: reuse explicit authorization for that bounded test, or ask once if none exists; IT access approval alone is not permission to send. Never ask again for the same authorized test. Before a real integration test, run \`harbour integrations status --app-root . --json\`; explain pending IT approval or missing personal consent before pressing Send, and test the ready parts independently. If you have no browser, say that the button itself is untested.
198
- - "I need Slack / Gmail / the warehouse / company data" → a fresh \`harbour init\` declares no connection at all, which is why a new app ships with nothing waiting on IT. Never guess a connection, channel, table, view, mailbox or warehouse column — a guessed name is refused before IT's queue ever sees it. Run \`harbour integrations catalog --app-root . --json\` first and use only what it lists. A warehouse resource whose catalog columns are \`["*"]\` is declared and read with \`["*"]\`; do not invent a friendlier schema or ask the person to get IT to confirm one. Declare only connections and operations the app really calls, then submit both development and preview requests yourself in the same turn with \`harbour integrations request <connection> --reason "<what the app does with it>" --app-root . --json\` and the same command with \`--environment preview\`. Never tell the person to ask IT before you have submitted the request. READY means use it now. PENDING means IT has to approve it: say "IT has to approve this; the app works without it until then", and check later with \`harbour integrations status --app-root . --json\`. A refusal with \`RESOURCE_NOT_APPROVED\` means the named resource is not on the connection yet: IT adds it in the Harbour console under Controls & integrations, and then you run the same request again. Never declare a connection the app does not call — every declared one blocks shipping until IT approves it.
199
- - "summarise", "draft", "explain", "AI" → one \`harbour.ai.chat\` call (through \`ai()\` in \`src/harbour.client.ts\`) behind a control the person presses; never an OpenAI/Anthropic key, SDK or URL. \`harbour check\` writes its journey. Send \`messages\` and \`maxTokens\` and nothing else: a refusal with \`unsupported_request_capability\` names a field the company's AI route does not accept — remove that field. A refusal with \`AI_NOT_ENABLED\` means IT has to enable an AI provider: say so in one line and keep the app working without it.
198
+ - "I need Slack / Gmail / the warehouse / company data" → a fresh \`harbour init\` declares no connection at all, which is why a new app ships with nothing waiting on IT. Never guess a connection, channel, table, view, mailbox or warehouse column — a guessed name is refused before IT's queue ever sees it. Before writing a warehouse query or mapping response fields, run \`harbour integrations catalog --app-root . --json\`, select a listed table or view, and copy its listed column names exactly. If the required object or column is absent, report that catalog gap instead of substituting a plausible name. A warehouse resource whose catalog columns are \`["*"]\` is declared and read with \`["*"]\`; inspect the returned row keys before mapping them and do not invent a friendlier schema or ask the person to get IT to confirm one. Declare only connections and operations the app really calls, then submit both development and preview requests yourself in the same turn with \`harbour integrations request <connection> --reason "<what the app does with it>" --app-root . --json\` and the same command with \`--environment preview\`. Never tell the person to ask IT before you have submitted the request. READY means use it now. PENDING means IT has to approve it: say "IT has to approve this; the app works without it until then", and check later with \`harbour integrations status --app-root . --json\`. A refusal with \`RESOURCE_NOT_APPROVED\` means the named resource is not on the connection yet: IT adds it in the Harbour console under Controls & integrations, and then you run the same request again. Never declare a connection the app does not call — every declared one blocks shipping until IT approves it.
199
+ - "summarise", "draft", "explain", "AI" → one \`harbour.ai.chat\` call (on the \`harbour\` client from \`src/harbour.client.ts\`, never through a wrapper function) behind a control the person presses; never an OpenAI/Anthropic key, SDK or URL. \`harbour check\` writes its journey. Send \`messages\` and \`maxTokens\` and nothing else: a refusal with \`unsupported_request_capability\` names a field the company's AI route does not accept — remove that field. A refusal with \`AI_NOT_ENABLED\` means IT has to enable an AI provider: say so in one line and keep the app working without it.
200
200
  - "ship it", "put it online", "let my team try it" → run \`harbour check --app-root . --json\` first and fix everything it finds, every time, unasked: the same gates run again in the cloud, where each failed attempt costs minutes instead of the seconds it costs here. Before sharing or deploying the private preview, show the person the exact proposed app name, description and audience that will be passed to Harbour (say "only you" when the audience is empty) and ask for one confirmation or correction covering all three; do not run \`productionise\` until they confirm. Pass that confirmed setup in the same command: \`harbour productionise --app-root . --name "<app name>" --description "<description>" --emails "<comma-separated audience>" --wait --json\`; omit \`--emails\` for "only you". The command records the confirmed setup before uploading or deploying the app. Then each declared connection needs a preview grant (\`harbour integrations request <connection> --environment preview --reason "…" --app-root . --json\`); when the app calls governed AI, \`productionise\` also asks whether the company's AI setup is ready and refuses with \`AI_NOT_READY\` and the one IT step — say that line and nothing more; the app works without AI until then. The command gives them \`result.deployment.protectedUrl\`: a private preview that they, and the people they confirmed, open after company sign-in. If your tool cuts the command off before it finishes, \`${continueCommand("<ref>")}\` continues the same deployment — never start another one to find out what happened. For kit apps, describe \`TRANSFORMING\` as building and checking the app; it does not mean a transformation AI is running. The CLI saves \`operationRef\` in \`.harbour/local/productionise.json\`; repeating \`productionise\` continues that operation. Keep \`operationRef\`; \`harbour setup --operation <ref> --json\` lists what is still missing (name, audience, secrets) and \`harbour profile\` / \`harbour audience\` / \`harbour secrets set\` fill it in.
201
201
  - "make it live for everyone", "go to production" → only after they have tried the preview. Before \`harbour promote --operation <ref> --json\`, show the exact app name, description and production audience again and ask for one confirmation or correction covering all three; never promote a profile or audience the person has not just seen and confirmed. Then promote with the operation reference from productionise. Report the production link, or that an operator approval is pending.
202
202
  - "stop it" → \`harbour stop --app-root .\` (local data kept). \`harbour dev --reset --app-root .\` deletes local data — only when they explicitly ask to start over.
@@ -1,6 +1,6 @@
1
1
  export const PUBLISHED_KIT_BUNDLE = {
2
2
  "schema": "harbour.kit-bundle/1.0",
3
- "kitVersion": "0.1.42",
3
+ "kitVersion": "0.1.47",
4
4
  "sdk": {
5
5
  "package": "@harbour/app-sdk",
6
6
  "version": "1.1.2",
@@ -8,21 +8,21 @@ export const PUBLISHED_KIT_BUNDLE = {
8
8
  "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:aa6dd568b8580ef239c32b7470e79681a54363ce7808f687e389697ef8f56d94"
9
9
  },
10
10
  "images": {
11
- "appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:771d71975a503214fa63f94534c77483f06797f8089d7fdc027ca30e26983039",
12
- "sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:c3c84639da9d98c08009bacaf4017ad597bc5528acda4cee9ad0917cee35afdb"
11
+ "appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:cce105d4f01d1389c57c50786e2d64216ef26ac6d36fbbc0f0e12b05fea22d8a",
12
+ "sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:7a14aeb938c815f6f42a1a850e8b470f8a0e3b532c1391a73377ce72931023b3"
13
13
  },
14
14
  "nativeRuntime": {
15
15
  "darwinArm64": {
16
- "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:2e83e6dd8747859ffabb6a832a759e95fc68aad9b374c0e16007da84aca49580",
17
- "sha256": "2e83e6dd8747859ffabb6a832a759e95fc68aad9b374c0e16007da84aca49580"
16
+ "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:2a28ad794fa976d5e50f410dc88ab474544fb0686727fd41339d145b825d78d4",
17
+ "sha256": "2a28ad794fa976d5e50f410dc88ab474544fb0686727fd41339d145b825d78d4"
18
18
  },
19
19
  "linuxX64": {
20
- "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:02ce306b179db95c16e1d3cedf6ad01d8dfba5810a2df14a03dcd2fd024478ef",
21
- "sha256": "02ce306b179db95c16e1d3cedf6ad01d8dfba5810a2df14a03dcd2fd024478ef"
20
+ "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:cb47104e302ab5cc2363f08d3d778c10f0c41845f22f1fba102c152859f82e44",
21
+ "sha256": "cb47104e302ab5cc2363f08d3d778c10f0c41845f22f1fba102c152859f82e44"
22
22
  },
23
23
  "windowsX64": {
24
- "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:2cee6b4521cd7a6d6bcf7b4806e18ac26ada150f13bf45dfda06346a96a47d75",
25
- "sha256": "2cee6b4521cd7a6d6bcf7b4806e18ac26ada150f13bf45dfda06346a96a47d75"
24
+ "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:35c3912f8af054594d8c0fd4cf157be4ccfdbacf7ab0da4783c9179d80ebb927",
25
+ "sha256": "35c3912f8af054594d8c0fd4cf157be4ccfdbacf7ab0da4783c9179d80ebb927"
26
26
  }
27
27
  },
28
28
  "brief": {
@@ -210,6 +210,14 @@ export function outcomeFor(status, operationRef) {
210
210
  }
211
211
  if (status.deployment)
212
212
  return { ...summary, outcome: "running" };
213
+ // No deployment yet has two very different causes. A save that stalled or
214
+ // failed (RETRYABLE_FAILURE, FAILED) is resumed by running `productionise`
215
+ // again — observed 2026-09-14 (fourier "Dad Jokes"): a builder read the
216
+ // administrator sentence for a save that was simply resumable. Only a
217
+ // SUCCEEDED save with no deployment behind it is the company-setup case.
218
+ const save = status.sourceSave?.status;
219
+ if (save && save !== "SUCCEEDED")
220
+ return { ...summary, outcome: "no_deployment", nextStep: summary.nextStep ?? `Harbour has not finished saving the app (${save}). Run \`harbour productionise\` again for this app: it resumes the same save and deploys; nothing is deployed twice.` };
213
221
  return { ...summary, outcome: "no_deployment", nextStep: summary.nextStep ?? "Harbour saved the app but has not started a deployment for this company yet. Ask your Harbour administrator to connect deployment." };
214
222
  }
215
223
  /**
@@ -432,11 +432,18 @@ function outcomeLine(summary) {
432
432
  }
433
433
  }
434
434
  const readApproval = readLine;
435
- /** The local gate's inventory says whether the app calls governed AI (`.harbour/local/check-report.json`, `gate.inventory.capabilities`). */
435
+ /**
436
+ * Whether the app really calls governed AI: the gate's inventory of
437
+ * `harbour.ai.*` call sites (`.harbour/local/check-report.json`,
438
+ * `gate.inventory.aiCallsites`). Not the capability word — a starter that
439
+ * merely carried an `ai()` accessor was derived as `ai` and would have been
440
+ * refused `AI_NOT_READY` at a company without AI for an app that never asks
441
+ * for it (2026-09-14). The call sites are what the deployment PLAN sees.
442
+ */
436
443
  export async function appCallsAi(root) {
437
444
  const report = await readReport(root);
438
- const capabilities = report?.gate?.inventory?.capabilities;
439
- return Array.isArray(capabilities) && capabilities.includes("ai");
445
+ const callsites = report?.gate?.inventory?.aiCallsites;
446
+ return Array.isArray(callsites) && callsites.length > 0;
440
447
  }
441
448
  async function finishDeployment(client, operationRef, output, options, verification = {}) {
442
449
  let summary;
@@ -50,11 +50,10 @@ const VERB_ORDER = ["insert", "upsert", "select", "update", "delete"];
50
50
  * Regenerates `.harbour/checks/` in place from the app's code and its own
51
51
  * database, and reports every decision. `declared`, when given, is the
52
52
  * capability set the kit gate derived from the source — the set its coverage
53
- * gate will demand evidence for — and it is the authority: the kit lane traces
54
- * a namespace wrapper (`export function ai() { return harbour.ai }`) to the
55
- * capability it wraps whether or not anything calls it, which a call-site
56
- * reading of the source never sees. Without it (tests, no gate) the source
57
- * reading decides.
53
+ * gate will demand evidence for — and it is the authority: the kit lane and
54
+ * this reading agree on the one call shape the starter teaches
55
+ * (`harbour.<namespace>.<method>(`), for which the lane's derivation is exact.
56
+ * Without it (tests, no gate) the source reading decides.
58
57
  */
59
58
  export async function syncRetainedChecks(root, schema, declared) {
60
59
  const plan = await planRetainedChecks(root, schema, declared);
@@ -108,7 +108,7 @@ export async function sourceTableVerbs(root) {
108
108
  */
109
109
  export async function capabilityUsage(root) {
110
110
  const files = await sourceFiles(root);
111
- const texts = await Promise.all(files.map(path => readFile(path, "utf8").catch(() => "")));
111
+ const texts = (await Promise.all(files.map(path => readFile(path, "utf8").catch(() => "")))).map(maskNonCode);
112
112
  const clients = new Set();
113
113
  for (const text of texts)
114
114
  for (const match of text.matchAll(CLIENT_BINDING))
@@ -155,8 +155,8 @@ const appPath = (root, path) => relative(root, path).split(sep).join("/");
155
155
  * receiver makes this a superset of that set — every call site the pipeline can
156
156
  * trace, plus ones it cannot — so a capability this cannot see is one the kit
157
157
  * lane cannot declare either, and the reverse gate refuses only trees the
158
- * pipeline already refuses. Where it errs it errs by staying quiet: a
159
- * commented-out call still counts here, and the pipeline masks comments.
158
+ * pipeline already refuses. Comments and string bodies are masked first,
159
+ * exactly as the pipeline masks them, so a quoted example never counts.
160
160
  */
161
161
  export async function capabilityCallSurface(root) {
162
162
  const surface = new Set();
@@ -165,7 +165,75 @@ export async function capabilityCallSurface(root) {
165
165
  surface.add(capability);
166
166
  return surface;
167
167
  }
168
+ /**
169
+ * The pipeline's reading masks comments and string bodies before it looks for
170
+ * call shapes (transformbuild maskJavaScriptNonCode); this reading does the
171
+ * same, so a call shape quoted in a comment — the starter's own examples —
172
+ * or inside a string never counts as a use. Offsets are preserved (masked
173
+ * bytes become spaces), so nothing that reports a position drifts.
174
+ */
175
+ export function maskNonCode(source) {
176
+ const out = source.split("");
177
+ let state = "code";
178
+ for (let i = 0; i < source.length; i += 1) {
179
+ const c = source[i], n = source[i + 1];
180
+ if (state === "code") {
181
+ if (c === "/" && n === "/") {
182
+ state = "line";
183
+ out[i] = " ";
184
+ continue;
185
+ }
186
+ if (c === "/" && n === "*") {
187
+ state = "block";
188
+ out[i] = " ";
189
+ continue;
190
+ }
191
+ if (c === "'")
192
+ state = "single";
193
+ else if (c === "\"")
194
+ state = "double";
195
+ else if (c === "`")
196
+ state = "template";
197
+ continue;
198
+ }
199
+ if (state === "line") {
200
+ if (c === "\n")
201
+ state = "code";
202
+ else
203
+ out[i] = " ";
204
+ continue;
205
+ }
206
+ if (state === "block") {
207
+ if (c === "*" && n === "/") {
208
+ state = "code";
209
+ out[i] = " ";
210
+ out[i + 1] = " ";
211
+ i += 1;
212
+ }
213
+ else if (c !== "\n")
214
+ out[i] = " ";
215
+ continue;
216
+ }
217
+ // A string body: keep the quotes (the shape around them stays readable), mask what is inside.
218
+ const quote = state === "single" ? "'" : state === "double" ? "\"" : "`";
219
+ if (c === "\\") {
220
+ out[i] = " ";
221
+ if (n !== undefined && n !== "\n") {
222
+ out[i + 1] = " ";
223
+ i += 1;
224
+ }
225
+ continue;
226
+ }
227
+ if (c === quote) {
228
+ state = "code";
229
+ continue;
230
+ }
231
+ if (c !== "\n")
232
+ out[i] = " ";
233
+ }
234
+ return out.join("");
235
+ }
168
236
  /** The same reading of one piece of text, so a retained check is judged by exactly the rule the app's own source is read with (retained-checks.ts). */
169
237
  export function capabilitiesCalled(text) {
170
- return new Set([...text.matchAll(CAPABILITY_CALL)].map(match => match[1]));
238
+ return new Set([...maskNonCode(text).matchAll(CAPABILITY_CALL)].map(match => match[1]));
171
239
  }
@@ -98,7 +98,7 @@ export function managedBlock() {
98
98
  "- A Slack message is posted either as the app (the company's Slack bot, Isomorph AI, under the IT-approved name: declare `\"presentation\": { \"displayName\": \"<app name>\" }` on the Slack connection, optional `iconEmoji`; every app-mode post ends with \"Posted by <app> on Isomorph\") or as the person (`\"identity\": \"user\"` on `slack.message.post`, their own consent; an older consent answers `USER_RECONNECT_REQUIRED` — offer Connect again) — never pretend one is the other. The declaration is the mode you request access for; when the app is approved for both, every post says which with `mode: \"app\"` or `mode: \"user\"` on the call (`MODE_REQUIRED` otherwise), and a mode IT has not approved is refused with `MODE_NOT_GRANTED`, never swapped. A post needs the bot in the channel: `/invite @Isomorph AI` there first.",
99
99
  "- Consent is only for user-identity operations (`slack.channel.history`, `gmail.*`, and `slack.message.post` declared `\"identity\": \"user\"`): call `harbour.integrations.connect(connection)` and, when it returns `consent_required`, open `authorizationUrl`. App-identity operations (`slack.message.post` declared `\"identity\": \"app\"`, `warehouse.view.read`) never call connect — it is refused as unapproved user access. Missing consent never falls back to another account.",
100
100
  "- A retained check that calls `harbour.integrations.execute` is answered, under `harbour check` and in the deployment pipeline alike, by the gate's fixture: the contract's result shape for a connection, operation and resource the app declared (one canned Slack message, one canned Gmail thread, a warehouse view with no rows), a refusal with the platform's own code for anything undeclared, and nothing is ever sent or read. The passed check says so in the report; real access is exercised only by `harbour check --integrations` (reads) and the preview's own smoke test.",
101
- "- AI goes through `harbour.ai` only — `ai().chat({ messages, maxTokens })` from `src/harbour.client.ts` — behind an explicit control the person presses (never on load, in an effect or a timer). Never add an OpenAI/Anthropic/Gemini key, SDK or URL: the platform's governed AI gateway holds the key and IT sees every call. The starter calls no AI; add the one call when the person asks for it (README.md has the \"Summarise my notes\" example) and `harbour check` writes `.harbour/checks/ai-journey.mjs` for it. A refusal with code `AI_NOT_ENABLED` means IT has not enabled an AI provider yet; the app must still work without AI.",
101
+ "- AI goes through `harbour.ai` only — `harbour.ai.chat({ messages, maxTokens })` on the `harbour` client from `src/harbour.client.ts`, never through a wrapper function (the gate reads only the direct call) — behind an explicit control the person presses (never on load, in an effect or a timer). Never add an OpenAI/Anthropic/Gemini key, SDK or URL: the platform's governed AI gateway holds the key and IT sees every call. The starter calls no AI; add the one call when the person asks for it (README.md has the \"Summarise my notes\" example) and `harbour check` writes `.harbour/checks/ai-journey.mjs` for it. A refusal with code `AI_NOT_ENABLED` means IT has not enabled an AI provider yet; the app must still work without AI.",
102
102
  "- Authentication is owned by Harbour SSO. Do not add login forms, JWT handling, or trust a role, owner id or tenant id supplied by the browser. Row ownership is decided in SQL through `current_setting('harbour.user_id', true)` and `current_setting('harbour.user_email', true)`.",
103
103
  "- Every route needs a signed-in human by default; do not add public routes or wildcard exceptions to make something work.",
104
104
  "- No secrets, tokens, `.env` values or fetched company content in source. `.harbour/local/` is ignored and never committed; `.harbour/integrations.json` and `.harbour/kit.lock.json` are committed.",
@@ -196,14 +196,14 @@ The checks and the code are one pair, and \`harbour check\` and the deployment p
196
196
 
197
197
  ## Adding AI ("Summarise my notes")
198
198
 
199
- The starter calls no AI. When the person asks for it, add one call through \`ai()\` from \`src/harbour.client.ts\`, behind a control they press — never on load, in an effect or a timer — and never an OpenAI/Anthropic/Gemini key, SDK or URL: the platform's governed AI gateway holds the key, IT enables the provider and sees every call.
199
+ The starter calls no AI. When the person asks for it, add one call — \`harbour.ai.chat(...)\` on the \`harbour\` client from \`src/harbour.client.ts\` — behind a control they press — never on load, in an effect or a timer — and never an OpenAI/Anthropic/Gemini key, SDK or URL: the platform's governed AI gateway holds the key, IT enables the provider and sees every call.
200
200
 
201
201
  \`\`\`tsx
202
202
  import { ai, errorCode } from "./harbour.client";
203
203
 
204
204
  const summarise = async () => {
205
205
  try {
206
- const reply = await ai().chat({ messages: [{ role: "user", content: \`Summarise these notes in three lines:\\n\${notes.map(n => n.title).join("\\n")}\` }], maxTokens: 200 });
206
+ const reply = await harbour.ai.chat({ messages: [{ role: "user", content: \`Summarise these notes in three lines:\\n\${notes.map(n => n.title).join("\\n")}\` }], maxTokens: 200 });
207
207
  setSummary(reply.content);
208
208
  } catch (error) {
209
209
  setError(errorCode(error) === "AI_NOT_ENABLED" ? "AI is not enabled for this company yet; ask IT to connect a provider." : "The summary could not be produced.");
@@ -242,15 +242,15 @@ Add one in two steps, when the app really calls it:
242
242
 
243
243
  2. Ask IT for access: \`harbour integrations request <connection> --reason "<why>" --app-root .\` for local development, and the same command with \`--environment preview\` for the preview grant every declared connection needs before \`harbour productionise\` will deploy. \`harbour integrations status --app-root .\` says where each request stands; PENDING is not ready.
244
244
 
245
- Then call it from the app through \`integrations()\` in \`src/harbour.client.ts\`:
245
+ Then call it from the app on the \`harbour\` client from \`src/harbour.client.ts\` — always this exact shape, never a wrapper function, because the kit gate derives what the app uses from it:
246
246
 
247
247
  \`\`\`ts
248
- const report = await integrations().execute("sales-warehouse", {
248
+ const report = await harbour.integrations.execute("sales-warehouse", {
249
249
  operation: "warehouse.view.read", resource: "weekly_sales", input: { columns: ["week", "total"], limit: 100 }
250
250
  });
251
251
  \`\`\`
252
252
 
253
- In browser code, a send (\`slack.message.post\`, \`gmail.message.send\`) runs only from an explicit Send control, pressed by the person or by the AI tool for an explicitly authorized test, with a fresh UUID \`idempotencyKey\` per press — never from an effect, a browser timer or a check. Scheduled work lives in \`jobs/<name>.ts\`; its handler acts as the app, so only app-mode sends may run there, with an approved destination and a deterministic idempotency key for the business period and destination. Test the schedule immediately with \`harbour jobs run <name> --app-root . --scheduled-at <UTC> --json\`; local job runs and checks use fixtures and send nothing, and the deployed Kubernetes schedule owns the real clock. Consent is only for user-identity operations (\`slack.channel.history\`, \`gmail.*\`, and \`slack.message.post\` declared \`"identity": "user"\`): \`integrations().connect(connection)\`, and open its \`authorizationUrl\` when it answers \`consent_required\`; app-identity operations (\`slack.message.post\` declared \`"identity": "app"\`, \`warehouse.view.read\`) never call connect. Anything the app calls needs its own retained check under \`.harbour/checks/\`, and anything it stops calling loses its check in the same edit. Keep request construction in an app function that both the button and its retained check call, so the check exercises the actual payload. Do not bypass the SDK types with a generic string/unknown wrapper. A check that calls \`integrations().execute\` is answered by the gate's fixture — the contract's result shape for the declared connection, operation and resource, nothing sent or read, and the report says so; \`harbour check --integrations\` is what exercises real access.
253
+ In browser code, a send (\`slack.message.post\`, \`gmail.message.send\`) runs only from an explicit Send control, pressed by the person or by the AI tool for an explicitly authorized test, with a fresh UUID \`idempotencyKey\` per press — never from an effect, a browser timer or a check. Scheduled work lives in \`jobs/<name>.ts\`; its handler acts as the app, so only app-mode sends may run there, with an approved destination and a deterministic idempotency key for the business period and destination. Test the schedule immediately with \`harbour jobs run <name> --app-root . --scheduled-at <UTC> --json\`; local job runs and checks use fixtures and send nothing, and the deployed Kubernetes schedule owns the real clock. Consent is only for user-identity operations (\`slack.channel.history\`, \`gmail.*\`, and \`slack.message.post\` declared \`"identity": "user"\`): \`harbour.integrations.connect(connection)\`, and open its \`authorizationUrl\` when it answers \`consent_required\`; app-identity operations (\`slack.message.post\` declared \`"identity": "app"\`, \`warehouse.view.read\`) never call connect. Anything the app calls needs its own retained check under \`.harbour/checks/\`, and anything it stops calling loses its check in the same edit. Keep request construction in an app function that both the button and its retained check call, so the check exercises the actual payload. Do not bypass the SDK types with a generic string/unknown wrapper. A check that calls \`harbour.integrations.execute\` is answered by the gate's fixture — the contract's result shape for the declared connection, operation and resource, nothing sent or read, and the report says so; \`harbour check --integrations\` is what exercises real access.
254
254
  `;
255
255
  const VITE_CONFIG = `import { defineConfig } from "vite";
256
256
  import react from "@vitejs/plugin-react";
@@ -275,37 +275,24 @@ const HARBOUR_CLIENT = `import { createClient } from "@harbour/app-sdk";
275
275
  // the same calls work locally (harbour dev) and in preview/production.
276
276
  export const harbour = createClient();
277
277
 
278
- // Keep the SDK's operation-specific inputs and inferred results intact.
279
- export type Integrations = typeof harbour.integrations;
280
- export function integrations(): Integrations { return harbour.integrations; }
281
-
278
+ // One call shape, everywhere: the SDK's own namespaces on this client —
279
+ // harbour.integrations.execute(...), harbour.ai.chat(...), harbour.data.from(...),
280
+ // harbour.files.* — and no wrapper functions around them. The kit gate derives
281
+ // what the app uses from exactly these calls; a wrapper would hide them.
282
+ //
282
283
  // Gmail sends plain text as the signed-in person, after IT approval and personal consent.
283
284
  // Inside an authorized Send handler (use the catalog's actual connection and mailbox):
284
- // await integrations().execute("company-gmail", {
285
+ // await harbour.integrations.execute("company-gmail", {
285
286
  // operation: "gmail.message.send", resource: "inbox",
286
287
  // input: { to: ["recipient@example.com"], subject: "Update", text: "Your message" },
287
288
  // idempotencyKey: crypto.randomUUID()
288
289
  // });
289
-
290
- export type AiMessage = { role: "system" | "user" | "assistant"; content: string };
291
- export type AiChatResult = { content: string; model: string; finishReason: string; usage: { inputTokens: number; outputTokens: number }; traceId?: string };
292
- export type Ai = {
293
- chat(request: { messages: AiMessage[]; model?: string; maxTokens?: number; temperature?: number }): Promise<AiChatResult>;
294
- embed(request: { input: string | string[]; model?: string }): Promise<{ embeddings: number[][]; model: string }>;
295
- };
296
-
297
- /**
298
- * Governed AI through the platform's LLM gateway (kit bundle SDK): the app never holds a
299
- * provider key and IT governs every call. The starter calls no AI; this is the entry point
300
- * for the one call you add when the person asks for it — behind an explicit control they
301
- * press, never on load. README.md has the "Summarise my notes" example. A HarbourError whose
302
- * errorCode() is AI_NOT_ENABLED means IT has not enabled a provider yet: keep the app working.
303
- */
304
- export function ai(): Ai {
305
- const surface = (harbour as { ai?: Ai }).ai;
306
- if (!surface) throw new Error("This @harbour/app-sdk build has no ai surface; run harbour init --upgrade.");
307
- return surface;
308
- }
290
+ //
291
+ // Governed AI through the platform's LLM gateway: the app never holds a provider key and
292
+ // IT sees every call. The starter calls no AI; when the person asks for it, add the one
293
+ // call — harbour.ai.chat({ messages, maxTokens }) — behind an explicit control they press,
294
+ // never on load. README.md has the "Summarise my notes" example. A HarbourError whose
295
+ // errorCode() is AI_NOT_ENABLED means IT has not enabled a provider yet: keep the app working.
309
296
 
310
297
  export function errorCode(error: unknown): string {
311
298
  const details = (error as { details?: { code?: string } } | undefined)?.details;
@@ -362,19 +349,19 @@ export function App() {
362
349
  // IT-approved presentation name; "identity": "user" posts as the signed-in person after their consent.
363
350
  // A post may name the approved mode it runs under — { ..., mode: "app" } — and must once IT approved both.)
364
351
  // 2. Request access (harbour integrations request sales-warehouse --reason "<why>" --app-root .,
365
- // and again with --environment preview before harbour productionise), then import
366
- // { integrations } from "./harbour.client" and uncomment:
367
- // const report = await integrations().execute("sales-warehouse", {
352
+ // and again with --environment preview before harbour productionise), then uncomment
353
+ // (the harbour client is already imported from "./harbour.client"):
354
+ // const report = await harbour.integrations.execute("sales-warehouse", {
368
355
  // operation: "warehouse.view.read", resource: "weekly_sales", input: { columns: ["week", "total"], limit: 100 }
369
356
  // });
370
357
  // A Slack send runs only from an explicit Send control, pressed by the person or by the AI tool for an explicitly authorized test, with a
371
358
  // fresh UUID idempotencyKey per press — never from an effect, a timer or a check.
372
359
  //
373
360
  // Governed AI is not part of the starter either. When the person asks for it, add ONE
374
- // call through ai() from "./harbour.client" behind a control they press (README.md
361
+ // call harbour.ai.chat on the client from "./harbour.client" behind a control they press (README.md
375
362
  // "Adding AI"), never an OpenAI/Anthropic key or SDK; harbour check then writes
376
363
  // .harbour/checks/ai-journey.mjs for it, and deletes it again if the call goes:
377
- // const summary = await ai().chat({ messages: [{ role: "user", content: \`Summarise these notes in three lines:\\n\${notes.map(n => n.title).join("\\n")}\` }], maxTokens: 200 });
364
+ // const summary = await harbour.ai.chat({ messages: [{ role: "user", content: \`Summarise these notes in three lines:\\n\${notes.map(n => n.title).join("\\n")}\` }], maxTokens: 200 });
378
365
 
379
366
  return (
380
367
  <main style={{ fontFamily: "system-ui", maxWidth: 720, margin: "2rem auto", padding: "0 1rem" }}>
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.1.46";
1
+ export const CLI_VERSION = "0.1.47";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fourier-labs/harbour",
3
- "version": "0.1.46",
3
+ "version": "0.1.47",
4
4
  "description": "Harbour productionisation helper",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,7 +37,7 @@
37
37
  "harbour": {
38
38
  "kitBundle": {
39
39
  "repository": "public.ecr.aws/y6t4p3i8/harbour-kit-bundle",
40
- "version": "0.1.42"
40
+ "version": "0.1.47"
41
41
  }
42
42
  }
43
43
  }