@isomorph.ai/cli 0.2.2-rc.1 → 0.3.0-rc.1

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/README.md CHANGED
@@ -10,7 +10,7 @@ You describe the app in plain English inside Claude Code or Codex; the agent ins
10
10
  npx -y @isomorph.ai/cli agent-setup
11
11
  ```
12
12
 
13
- Paste that line into Claude Code or Codex (or a terminal). It teaches both agents the kit by installing the `isomorph` skill for each (`~/.claude/skills/isomorph/SKILL.md`, `~/.codex/skills/isomorph/SKILL.md`) and never touches your other skills or instructions. A skill is read only when the task matches it — a folder with `.isomorph/`, an app you ask for on Isomorph — so the agent works exactly as before on everything else. (Releases before 0.1.34 put the guide in your global Codex instructions, `~/.codex/AGENTS.md`, where every Codex session read it; running the line again takes that block out and leaves the rest of the file as it was.)
13
+ Paste that line into Claude Code or Codex (or a terminal). It teaches both agents the kit by installing the `isomorph` skill for each (`~/.claude/skills/isomorph/SKILL.md`, `~/.codex/skills/isomorph/SKILL.md`, with the kit's rules beside it as `core.md`, `integrations.md`, `ai.md` and `jobs.md`) and never touches your other skills or instructions. A skill is read only when the task matches it — a folder with `.isomorph/`, an app you ask for on Isomorph — so the agent works exactly as before on everything else. (Releases before 0.1.34 put the guide in your global Codex instructions, `~/.codex/AGENTS.md`, where every Codex session read it; running the line again takes that block out and leaves the rest of the file as it was.)
14
14
 
15
15
  That `npx` line runs the current release, but `isomorph init`, `dev` and `check` afterwards run whatever `isomorph` is installed on this machine. So `agent-setup` also compares the two and says, in its output and in `result.cli` of `--json` (`state`, `upgradeRequired`, `remediation`), when the installed CLI is missing or behind — a stale one carries an old kit bundle and builds an app the deployment pipeline refuses. The fix is always `npm i -g @isomorph.ai/cli`.
16
16
 
@@ -35,7 +35,7 @@ isomorph check --app-root <path> [--integrations] [--json] declaration, types,
35
35
  isomorph integrations catalog --app-root <path> [--json] the company's connections as .isomorph/integrations.json names them, with approved channels/views per environment
36
36
  isomorph integrations request <connection> --app-root <path> [--reason <text>] one request per connection; IT approves it once for every environment (`isomorph dev` and `isomorph productionise` file it for you)
37
37
  isomorph integrations status --app-root <path> [--json]
38
- isomorph connect <work-email-or-start-url> once per company; then isomorph login | logout
38
+ isomorph connect <work-email | company-start-url> once per company; then isomorph login | logout
39
39
  isomorph productionise --app-root <path> [--wait] [--json] save + preview deployment, prints the protected link
40
40
  isomorph status | retry | promote | setup | profile | audience | secrets … --operation <reference>
41
41
  ```
@@ -46,10 +46,10 @@ isomorph status | retry | promote | setup | profile | audience | secrets … --o
46
46
 
47
47
  ## Apps set up by the old `harbour` CLI
48
48
 
49
- Before 0.2.0 the CLI was published as `@fourier-labs/harbour` with the command `harbour`, and it kept the kit's files under `.harbour/`. That layout is not read any more — not by this CLI and not by the deployment pipeline. Run `isomorph init --upgrade --app-root .` once in each such app: it renames `.harbour/` to `.isomorph/` (git records renames), moves the two committed schema ids with it, rewrites the managed block in CLAUDE.md / AGENTS.md, replaces the project skill (the old `.claude/skills/harbour-kit/SKILL.md` is deleted when it is the kit's — the frontmatter name and the opening line every release wrote — and kept, and reported as kept, when you rewrote its body), deletes the checks the old kit generated (the next `isomorph check` regenerates them) and rewrites the SDK package name, its exported type names and the retired `HARBOUR_*` environment names (`HARBOUR_APP_URL` → `ISOMORPH_APP_URL` and the rest) in `package.json`, `vite.config.*`, `src/`, `jobs/` and the checks you wrote yourself. Run it again after updating the CLI: an app already moved by 0.2.0 loses its old skill and old environment names the same way. Every other command refuses an un-upgraded app and names that command. The old CLI can stay installed; it no longer deploys.
49
+ Before 0.2.0 the CLI was published as `@fourier-labs/harbour` with the command `harbour`, and it kept the kit's files under `.harbour/`. That layout is not read any more — not by this CLI and not by the deployment pipeline. Run `isomorph init --upgrade --app-root .` once in each such app: it renames `.harbour/` to `.isomorph/` (git records renames), moves the two committed schema ids with it, rewrites the managed block in CLAUDE.md / AGENTS.md, deletes the project skill older CLIs wrote (`.claude/skills/harbour-kit/SKILL.md` up to 0.1.x, `.claude/skills/isomorph-kit/SKILL.md` up to 0.2.2 — when it is the kit's, by the frontmatter name and the opening line every release wrote; one whose body you rewrote is kept, and reported as kept; the rules now live beside the user-level skill and no project skill replaces it), deletes the checks the old kit generated (the next `isomorph check` regenerates them) and rewrites the SDK package name, its exported type names and the retired `HARBOUR_*` environment names (`HARBOUR_APP_URL` → `ISOMORPH_APP_URL` and the rest) in `package.json`, `vite.config.*`, `src/`, `jobs/` and the checks you wrote yourself. Run it again after updating the CLI: an app already moved by 0.2.0 loses its old skill and old environment names the same way. Every other command refuses an un-upgraded app and names that command. The old CLI can stay installed; it no longer deploys.
50
50
 
51
51
  ## Development
52
52
 
53
53
  Built from the [governance control plane](https://github.com/Fourier-Labs-AI/harbour-governance-control-plane) repository: `npm ci`, `npm run build --prefix packages/harbour-cli`, tests in `tests/cli-kit.test.ts` and `tests/cli-packaging-contract.test.ts`. Releases are tagged `cli-v<version>` by the release automation the moment `packages/harbour-cli/package.json` changes on `main`, so bump to a candidate first (`X.Y.Z-rc.1`, published under the `candidate` dist-tag through npm trusted publishing) and promote it with the `CLI release automation` workflow's `candidate` input; a final version on `main` without an accepted candidate is refused by the release job. 0.2.0 and 0.2.1 (the rename) were published by hand from their merge commits before the trusted publisher existed for `@isomorph.ai/cli`, which is why their `cli-v` release runs show red.
54
54
 
55
- Connect with `isomorph connect name@getlokal.com`: the CLI tries `https://platform.isomorph.ai/start/getlokal.json` and validates the profile before saving it. This uses the first domain label as a guess, not a domain registry; subdomains or companies whose tenant name differs should use their company setup link. Only the guessed tenant name is sent, not the email. Company sign-in and access checks still apply.
55
+ Connect with `isomorph connect name@getlokal.com`: the CLI asks the platform which company admits the email's domain (`https://platform.isomorph.ai/start/getlokal.com.json` — the same policies that admit the person at sign-in) and saves that company's profile. Only the domain is sent, not the email. When no company admits the domain, or more than one does, the CLI prints the platform's sentence (ask IT, or run the printed `isomorph connect <link>`) and saves nothing; a company's console page (`platform.isomorph.ai/t/<company>`) is accepted as the link. `isomorph login` then prints the company it signed in to and what IT has set up there. Company sign-in and access checks still apply.
@@ -5,6 +5,7 @@ import { dirname, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { promisify } from "node:util";
7
7
  import { CLI_VERSION } from "./version.js";
8
+ import { GUIDE_MODULES } from "./guide.js";
8
9
  import { continueCommand } from "./operations.js";
9
10
  const execFileAsync = promisify(execFile);
10
11
  export const MANAGED_START = "<!-- isomorph:kit:start -->";
@@ -19,7 +20,8 @@ export const CLI_INSTALL_COMMAND = "npm i -g @isomorph.ai/cli";
19
20
  * Where the two agents read the user-level guide from; both honour the tools' own
20
21
  * override variables. Both are skills — a file each tool lists by name and description
21
22
  * and reads in full only when the task matches — so the guide costs nothing in every
22
- * other session. `legacyCodexAgents` is Codex's global AGENTS.md, which releases up to
23
+ * other session. The rule modules (`guide.ts`) are written beside each skill file, so
24
+ * "core.md beside this skill" resolves for both agents. `legacyCodexAgents` is Codex's global AGENTS.md, which releases up to
23
25
  * 0.1.33 wrote the guide into; Codex reads that file in every session, in every
24
26
  * folder, so the kit's rules for a non-developer's Isomorph app ("never paste logs",
25
27
  * "end every report with three lines") were applied to all of a person's other work.
@@ -34,9 +36,10 @@ export function agentPaths(env = process.env) {
34
36
  };
35
37
  }
36
38
  /**
37
- * Installs the user-level Isomorph skill for Claude Code and Codex (one file each,
38
- * wholly owned by the kit) and takes the guide back out of Codex's global AGENTS.md
39
- * where an older release put it, keeping everything else in that file. Idempotent:
39
+ * Installs the user-level Isomorph skill for Claude Code and Codex — `SKILL.md` (the
40
+ * workflow) and the four rule modules beside it, every file wholly owned by the kit —
41
+ * and takes the guide back out of Codex's global AGENTS.md where an older release put
42
+ * it, keeping everything else in that file. Idempotent:
40
43
  * unchanged files are reported as kept. Also reports which `isomorph` the agent's next
41
44
  * command will run (`result.cli`), because this command is routinely reached through
42
45
  * `npx` while everything after it is not.
@@ -45,11 +48,16 @@ export async function agentSetup(env = process.env, lookups = {}) {
45
48
  const paths = agentPaths(env);
46
49
  const result = { created: [], updated: [], kept: [], removed: [], cli: await checkCliVersion(lookups) };
47
50
  for (const skill of [paths.claudeSkill, paths.codexSkill])
48
- result[await upsertManagedBlock(skill, `${SKILL_FRONTMATTER}\n${AGENT_GUIDE}`, {})].push(skill);
51
+ for (const file of skillFiles(skill))
52
+ result[await upsertManagedBlock(file.path, file.text, {})].push(file.path);
49
53
  if (await removeManagedBlock(paths.legacyCodexAgents, { start: MANAGED_START, end: MANAGED_END }))
50
54
  result.removed.push(paths.legacyCodexAgents);
51
55
  return result;
52
56
  }
57
+ /** What `agent-setup` writes into one skill directory: `SKILL.md` (the workflow) and one `<module>.md` per rule module beside it. */
58
+ export function skillFiles(skill) {
59
+ return [{ path: skill, text: `${SKILL_FRONTMATTER}\n${AGENT_GUIDE}` }, ...Object.entries(GUIDE_MODULES).map(([name, text]) => ({ path: join(dirname(skill), `${name}.md`), text }))];
60
+ }
53
61
  /** Compares the globally resolvable `isomorph` with the package this process runs from. */
54
62
  export async function checkCliVersion(lookups = {}) {
55
63
  const running = await (lookups.runningCliVersion ?? runningPackageVersion)();
@@ -190,46 +198,37 @@ const SKILL_FRONTMATTER = `---
190
198
  name: isomorph
191
199
  description: ${SKILL_DESCRIPTION}
192
200
  ---`;
193
- /** One guide, shared by the Claude Code and Codex skills. Written for an agent working with a non-developer, and only for that. */
201
+ /** One guide, shared by the Claude Code and Codex skills: the workflow only, written for an agent working with a non-developer. Every rule lives in a module (`guide.ts`) beside it and is pointed at, not restated. */
194
202
  export const AGENT_GUIDE = `# Isomorph development kit
195
203
 
196
- This guide is for one job: an app that runs on Isomorph. It applies when the folder has an \`.isomorph/\` directory, when the person names Isomorph, or when they ask for a new app for their team and the folder holds no other project. Anything else — another project, a script, a question, ordinary coding — is not this job: ignore every rule below and work as you always do. Never turn an existing project into an Isomorph app unless the person asks for that by name.
204
+ This guide is for one job: an app that runs on Isomorph — a folder with \`.isomorph/\`, or a new app the person asks for on Isomorph. Anything else is not this job: ignore every rule below and work as you always do. Never turn an existing project into an Isomorph app unless the person asks for that by name.
205
+
206
+ The person may not be a developer. They say what they want in plain English; you build it and run every command yourself. Never ask them to type a terminal command (the one exception is sign-in, below). Prefer \`--json\` output and read it yourself; never paste JSON, logs, stack traces or file contents at them.
197
207
 
198
- The person you are working with may not be a developer. They say what they want in plain English; you build it with the Isomorph development kit and run every command yourself. Never ask them to type a terminal command (the one exception is sign-in, below). Turn every failure into one sentence about what happened and one about what happens next. Prefer \`--json\` output and read it yourself; never paste JSON, logs, stack traces or file contents at them.
208
+ The kit's rules are the files beside this one: \`core.md\` before the first edit; \`integrations.md\`, \`ai.md\` and \`jobs.md\` before the work each names.
199
209
 
200
- ## Getting ready (do this yourself, once per machine and folder)
210
+ ## Getting ready (once per machine and folder)
201
211
 
202
- 1. CLI, before \`isomorph init\` or anything else: compare the installed CLI with the current release and upgrade it whenever it is behind — a CLI that merely runs is not good enough, only a current one is. \`isomorph agent-setup\` prints both versions and says plainly when the installed one is missing or older (its \`--json\` result carries \`cli.upgradeRequired\` and \`cli.remediation\`); without that output, compare \`isomorph --version\` with \`npx -y @isomorph.ai/cli --version\` yourself. If either says the installed one is missing or older, run \`npm i -g @isomorph.ai/cli\` and confirm \`isomorph --version\` now matches, then continue. Every later command runs the *installed* CLI, so a stale one builds an app that passes every local check and is then refused by the deployment pipeline (\`kit_bundle_incompatible\`) minutes later, with nothing in the app to fix. The kit needs Node 22+ on macOS Apple silicon, Linux x64 or Windows x64; it installs and runs its local database, files and gateway without Docker.
203
- 2. Folder: if the current folder has no \`.isomorph/\` directory, run \`isomorph init --app-root .\` — an empty folder gets a small starter app, an existing Vite + React app gets the kit files added and nothing overwritten. Then read the "Isomorph development kit" block in CLAUDE.md / AGENTS.md; it holds the per-app rules.
204
- 3. Sign-in, needed only for company systems and shipping: run \`isomorph login\`. It opens the browser and the person finishes the sign-in there — the one step they do themselves; tell them so in one line. If login says the company is not connected yet, ask for their work email and run \`isomorph connect <work-email>\` first. This guesses the tenant from the first email-domain label and validates its company profile; it does not grant access. If the lookup fails, ask for the company setup link their IT/admin gave them and run \`isomorph connect <link>\`.
212
+ 1. CLI, before \`isomorph init\` or anything else: the installed CLI must match the current release. \`isomorph agent-setup\` prints both versions and says when the installed one is missing or older (\`--json\`: \`cli.upgradeRequired\`, \`cli.remediation\`); otherwise compare \`isomorph --version\` with \`npx -y @isomorph.ai/cli --version\`. If it is missing or older, run \`npm i -g @isomorph.ai/cli\` and confirm \`isomorph --version\` now matches: every later command runs the installed CLI, and a stale one builds an app the deployment pipeline refuses (\`kit_bundle_incompatible\`). Node 22+, no Docker.
213
+ 2. Folder: if it has no \`.isomorph/\` directory, run \`isomorph init --app-root .\` — an empty folder gets a small starter app, an existing Vite + React app gets the kit files and nothing overwritten.
214
+ 3. Sign-in, needed only for company systems and shipping: run \`isomorph login\`. It opens the browser and the person finishes the sign-in there — the one step they do themselves; tell them so in one line. It then prints the company and what IT has set up. If it says the company is not connected yet, ask for their work email and run \`isomorph connect <work-email>\` — Isomorph looks the company up from the email's domain. Only if that fails use a link: if it answers that more than one company admits the domain, ask which one and run the command it printed; if it says no company admits the domain, their IT admin has to add it or give them a link (their Isomorph page \`https://platform.isomorph.ai/t/<company>\`, or the setup link), and then run \`isomorph connect <link>\`.
205
215
 
206
216
  ## What they say → what you do
207
217
 
208
- - "run it", "show me", "let me try it" → start \`isomorph 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 \`Isomorph 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.
209
- - "check it", "is it ok?", "is it ready?" → with dev running, \`isomorph check --app-root . --json\`, then read \`.isomorph/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.
210
- - "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 \`isomorph 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 \`isomorph 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.
211
- - "I need Slack / Gmail / the warehouse / company data" → a fresh \`isomorph 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 \`isomorph 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 the request yourself in the same turn with \`isomorph integrations request <connection> --reason "<what the app does with it>" --app-root . --json\` — one request per connection; IT approves it once for every environment (development, preview and production together), and \`isomorph dev\` and \`isomorph productionise\` file it for you as well, so there is never a second request to make before shipping. 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 \`isomorph integrations status --app-root . --json\` (development may already read ready while preview and production wait: that is the company's development preapproval, and the one approval covers the rest). A refusal with \`RESOURCE_NOT_APPROVED\` means the named resource is not on the connection yet: IT adds it in the Isomorph 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.
212
- - "summarise", "draft", "explain", "AI" → one \`isomorph.ai.chat\` call (on the client exported by \`src/isomorph.client.ts\`, never through a wrapper function) behind a control the person presses; never an OpenAI/Anthropic key, SDK or URL. \`isomorph 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.
213
- - "ship it", "put it online", "let my team try it" → run \`isomorph 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 Isomorph (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: \`isomorph 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. It files the access request for each declared connection itself and refuses with \`INTEGRATIONS_NOT_READY\` naming what IT still has to approve — say that line and nothing more, and ship again once IT has approved it (one approval covers preview and production); 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 \`.isomorph/local/productionise.json\`; repeating \`productionise\` continues that operation. Keep \`operationRef\`; \`isomorph setup --operation <ref> --json\` lists what is still missing (name, audience, secrets) and \`isomorph profile\` / \`isomorph audience\` / \`isomorph secrets set\` fill it in.
214
- - "make it live for everyone", "go to production" → only after they have tried the preview. Before \`isomorph 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.
215
- - "stop it" → \`isomorph stop --app-root .\` (local data kept). \`isomorph dev --reset --app-root .\` deletes local data — only when they explicitly ask to start over.
216
- - "every day at 2pm", "run this on a schedule", "send this automatically" → create one \`jobs/<name>.ts\` file with a literal UTC cron and one default async handler. Start \`isomorph dev\`, then run it immediately with \`isomorph jobs run <name> --app-root . --scheduled-at <matching-UTC-time> --json\`; this checks the job against local data and safe fixtures. If the person explicitly asks to test the company action now, use \`--real\` only after the exact action has development approval. Tell them: "Your scheduled task is ready. I can test the app on your computer now. Company messages will start after the app is online and access is approved." Never use \`setInterval\`, an effect or a browser timer as a scheduler; Isomorph runs the same declaration automatically after deployment.
218
+ - "run it", "show me", "let me try it" → start \`isomorph dev --app-root .\` in the background (the first start downloads the native runtime; a minute or two). Wait for \`Isomorph dev is running: http://127.0.0.1:<port>\` and give them that link — unasked as soon as the first check is green. Locally they are a fixture user; no company sign-in is needed.
219
+ - "check it", "is it ok?", "is it ready?" → with dev running, \`isomorph check --app-root . --json\`, then read \`.isomorph/local/check-report.json\`. Failures in the app's code are yours: fix, then check again until it is clean — after every change and before every ship, without being asked and without offering them as a choice.
220
+ - "does it work?", and before you report anything as working → open the dev link in your own browser, press the control you built or changed, and read what the app shows. A green \`isomorph check\` is not that proof: fixtures answer AI and company systems, so their refusals appear only when the control is really pressed. Test rendering, navigation, fixtures and approved reads automatically; a Send in development is real: reuse explicit authorization for that bounded test, or ask once if none exists (IT approval alone is not permission to send). If you have no browser, say that the button itself is untested.
221
+ - "I need Slack / Gmail / the warehouse / company data" → read \`integrations.md\` beside this skill first; then, in the same turn, read the catalog, declare only what the app calls, submit the access request yourself, and say READY or PENDING in one line.
222
+ - "summarise", "draft", "explain", "AI" → read \`ai.md\` beside this skill first; then one call behind a control they press.
223
+ - "ship it", "put it online", "let my team try it" → \`isomorph check --app-root . --json\` first, unasked, and fix everything it finds: the same gates run again in the cloud, minutes per attempt. Before sharing or deploying the private preview, show the exact proposed app name, description and audience that will be passed to Isomorph (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. Then \`isomorph productionise --app-root . --name "<app name>" --description "<description>" --emails "<comma-separated audience>" --wait --json\` (omit \`--emails\` for "only you"). It records the confirmed setup before uploading or deploying the app, files the access request for each declared connection itself and refuses with \`INTEGRATIONS_NOT_READY\` naming what IT still has to approve, or with \`AI_NOT_READY\` and the one IT step — say that line and nothing more; the app works without them until then. \`result.deployment.protectedUrl\` is the private preview, for them and the confirmed audience after company sign-in. If your tool cuts it off, \`${continueCommand("<ref>")}\` continues the same deployment — never start another one to find out what happened; \`TRANSFORMING\` means building and checking.
224
+ - "make it live for everyone", "go to production" → only after they have tried the preview. Before \`isomorph 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. Report the production link, or that an operator approval is pending.
225
+ - "stop it" → \`isomorph stop --app-root .\` (data kept); \`isomorph dev --reset --app-root .\` deletes local data, only when they ask to start over.
226
+ - "every day at 2pm", "run this on a schedule", "send this automatically" → read \`jobs.md\` beside this skill first; then write the job, run it once with \`isomorph jobs run\`, and say the sentence it gives you.
217
227
 
218
228
  When a command refuses, the refusal names its own reason and its own fix: change that one thing, then run it again. A failed app needs its reported fix; a wait timeout means work is still running, so continue the saved operation — and never start a second deploy of an app while one is running, because concurrent deploys of one app cancel each other.
219
229
 
220
- ## Building the app
221
-
222
- - Identity, data and files go through \`@isomorph.ai/app-sdk\` only: \`isomorph.identity.current()\`, \`isomorph.data.from(table)\`, \`isomorph.files.*\`. Company systems go only through \`isomorph.integrations.execute\` with declared operations. AI goes through \`isomorph.ai\`; never add an OpenAI/Anthropic key or SDK. Never open a database, bucket or company URL from browser code, and never add another backend, auth library or deployment config: the kit is the whole path.
223
- - Authentication is Isomorph SSO: no login forms, no roles or ids trusted from the browser; row ownership is decided in SQL through \`current_setting('harbour.user_id', true)\`. Every route needs a signed-in person; no public routes.
224
- - Schema changes are SQL files in \`migrations/\` with row-level security and GRANTs to \`harbour_app_gateway\`; \`isomorph dev\` and \`isomorph check\` apply them.
225
- - Know the operation's input bounds before writing a call: \`slack.channel.history\` \`input.limit\` 1..15, \`gmail.thread.list\` \`input.limit\` 1..15, \`warehouse.view.read\` \`input.limit\` 1..1000 (the connector's \`VIEW_READ_MAX_LIMIT\`); anything larger is refused with \`INPUT_INVALID\`, so page instead of asking for more.
226
- - In browser code, a Slack message or an email (\`slack.message.post\`, \`gmail.message.send\`) is sent only from an explicit Send control (pressed by the person, or by you for an explicitly authorized test), with a fresh UUID \`idempotencyKey\` per press (reused only to retry that press). Never send from an effect, a browser timer or during checks. A declared \`jobs/*.ts\` handler is the only scheduled-send path: it acts as the app, so only app-mode operations such as \`slack.message.post\` may run there; user-mode Slack and Gmail need a person and cannot. A real scheduled send requires the person's explicit request, the exact operation and destination declared in \`.isomorph/integrations.json\`, and a grant for that environment; use one deterministic idempotency key for the business period and destination so replay does not silently repost. Local job runs and \`isomorph check\` use fixtures and send nothing. Consent (\`isomorph.integrations.connect\`) exists only for user-identity operations (\`slack.channel.history\`, \`gmail.thread.list\`, \`gmail.message.read\`, \`gmail.message.send\`, and \`slack.message.post\` declared \`"identity": "user"\`); app-identity operations (\`slack.message.post\` declared \`"identity": "app"\`, \`warehouse.view.read\`) never call it — a connect for them is refused as unapproved user access and puts nothing in IT's queue. Whenever the app uses a user-identity operation, build its consent flow into the app during the original implementation without asking whether to add it: the operation's control calls \`isomorph.integrations.connect\`, follows \`authorizationUrl\` when the result is \`consent_required\`, returns to a clear connected state, and then lets the person continue the operation. Consent starts only from that person's control, never on page load. Missing consent never falls back to another account.
227
- - A Slack message is posted either as the app (\`"identity": "app"\` — the company's one Slack bot, Isomorph AI, under the name IT approved: declare \`"presentation": { "displayName": "<app name>", "iconEmoji": ":sandwich:" }\` on the connection and IT sees "posts as" before approving; leave it out to post as Isomorph AI itself) or as the person (\`"identity": "user"\` — their own Slack account, after their consent; an older consent answers \`USER_RECONNECT_REQUIRED\` "reconnect Slack to allow posting as you", so offer Connect again) — never pretend one is the other. The declaration in \`.isomorph/integrations.json\` is the mode the app requests access for; a post names the approved mode it runs under with \`mode: "app"\` or \`mode: "user"\` on the execute call — optional while the app is approved for one mode, required once IT approved both (\`MODE_REQUIRED\`), and a mode IT has not approved is refused with \`MODE_NOT_GRANTED\`, never swapped for the other. An app-mode post always ends with "Posted by <app> on Isomorph". A post is refused with \`RESOURCE_NOT_APPROVED\` until the bot is in the channel: say "IT (or anyone in the channel) has to run \`/invite @Isomorph AI\` in #<channel> first".
228
- - No secrets, tokens, \`.env\` values or fetched company content in source. \`.isomorph/local/\` is never committed; \`.isomorph/integrations.json\` and \`.isomorph/kit.lock.json\` are.
229
-
230
230
  ## Talking to the person
231
231
 
232
- - Plain words, short: "Your app is running at <link>.", "All 6 checks passed.", "One check failed: votes were not being saved — fixed, checking again.", "IT has to approve Slack; the app works without it until then."
233
- - Say what happens next and roughly how long it takes. Report only what you observed; if something is unknown, say so.
234
- - Keep app reports natural and brief: say what you actually verified, any remaining blocker and who can resolve it, and any material scope decision you made. Do not force headings or repeat unchanged status. A platform check is not proof that the main task worked in the browser.
235
- - End a turn with at most one question, and only when a decision is genuinely theirs to make and you cannot go on without it. Never offer to do something this guide already tells you to do unasked — do it and report what happened.`;
232
+ - Plain words, short: "Your app is running at <link>.", "IT has to approve Slack; the app works without it until then." Say what happens next and roughly how long it takes; report only what you observed, and say when something is unknown.
233
+ - Keep reports natural and brief: what you verified, any remaining blocker and who can resolve it, any material scope decision. No forced headings, no repeated status. A platform check is not proof that the main task worked in the browser.
234
+ - End a turn with at most one question, and only when a decision is genuinely theirs and you cannot go on without it. Never offer to do something this guide already tells you to do unasked — do it and report what happened.`;
@@ -1,6 +1,10 @@
1
1
  import { readFile, readdir } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- /** A policy that decides rows by the signed-in identity, and the column it decides them on. */
3
+ /**
4
+ * A policy that decides rows by the signed-in identity, and the column it decides them on.
5
+ * `IDENTITY` is rule `core.rls-owner-scoped` (guide.ts): any policy text naming the
6
+ * identity setting makes the table private, so a shared-read table keeps it out of SELECT.
7
+ */
4
8
  const OWNER_SCOPE = /([A-Za-z_][A-Za-z0-9_]*)\s*(?:\)|::[a-z ]+)*\s*=\s*current_setting\('harbour\.user_(?:id|email)'/i;
5
9
  const IDENTITY = /current_setting\('harbour\.user_(?:id|email)'/i;
6
10
  /**
@@ -5,7 +5,7 @@ import { confirmAudience, confirmProfile, continueCommand, dismissSecret, fetchS
5
5
  import { CliError, failureEnvelope, operationEnvelope, renderFailure, renderSummary } from "./output.js";
6
6
  import { CLI_VERSION } from "./version.js";
7
7
  import { connectedAccount, login, logout, refreshStoredToken } from "./auth.js";
8
- import { connect, loadConfig, resolveConfig } from "./config.js";
8
+ import { companyLabel, connect, loadConfig, resolveConfig } from "./config.js";
9
9
  import { EMBEDDED_KIT_BUNDLE } from "./kit-bundle.js";
10
10
  import { appRoot, assertKitCurrent, readKitLock, requestResourceName } from "./kit.js";
11
11
  import { initKit } from "./starter.js";
@@ -13,7 +13,7 @@ import { agentPaths, agentSetup, cliVersionLines } from "./agent-setup.js";
13
13
  import { startDev } from "./dev.js";
14
14
  import { ensureSdk, LocalRuntime, readDevLock, releaseDevLock, runCommand } from "./local-runtime.js";
15
15
  import { runChecks } from "./check.js";
16
- import { assertAiReady, GovernanceClient, groupGrants, IDENTITY_WORDS, integrationsCatalog, integrationsStatus, renderGrantGroup, renderIntegrationsCatalog, requestIntegrations } from "./integrations.js";
16
+ import { assertAiReady, companySystemsLine, GovernanceClient, groupGrants, IDENTITY_WORDS, integrationsCatalog, integrationsStatus, renderGrantGroup, renderIntegrationsCatalog, requestIntegrations } from "./integrations.js";
17
17
  import { runJob } from "./jobs.js";
18
18
  const args = process.argv.slice(2);
19
19
  const command = args[0];
@@ -60,7 +60,7 @@ for (let index = 0; index < args.length; index += 1) {
60
60
  const explicitToken = process.env.ISOMORPH_TOKEN?.trim() ?? "";
61
61
  const usage = [
62
62
  "Usage:",
63
- " isomorph connect <work-email-or-start-url>",
63
+ " isomorph connect <work-email | company-start-url>",
64
64
  " isomorph login | logout",
65
65
  " isomorph productionise --app-root <path> [--include <relative-path>]... [--name <text> --description <text> [--emails a@co,b@co]] [--no-wait] [--max-wait <seconds>] [--json]",
66
66
  " isomorph status --operation <reference> [--wait] [--max-wait <seconds>] [--json]",
@@ -81,7 +81,7 @@ const usage = [
81
81
  " isomorph integrations request <connection> --app-root <path> [--reason <text>] [--operations a,b] [--json] one request per connection, for every environment at once: IT approves it once (isomorph dev and productionise file it for you)",
82
82
  " isomorph integrations status --app-root <path> [--json]",
83
83
  " isomorph integrations catalog --app-root <path> [--json] the company's connections as .isomorph/integrations.json names them: identifiers, allowed operations, approved channels/tables/views/mailboxes per environment (no app needed)",
84
- "Run `isomorph connect <work-email-or-start-url>` once, then sign in when Isomorph asks.",
84
+ "Run `isomorph connect <work-email>` once; sign in with `isomorph login` when Isomorph asks.",
85
85
  "productionise saves the app, follows its deployment, and prints the protected preview link; promote sends a tested preview to production.",
86
86
  `--max-wait bounds how long productionise, status --wait, retry and promote follow the deployment (default 30 minutes). When it passes the command exits 0 with status RUNNING, the last known deployment state, and the \`${continueCommand("<reference>")}\` that continues the same deployment: a bounded wait is not a failure and never means start another one.`,
87
87
  "For kit apps, productionise first files the access request for every connection in .isomorph/integrations.json (one per connection; IT approves it once for every environment) and exits 2 (INTEGRATIONS_NOT_READY) naming what IT still has to approve, asks governance whether the company's AI setup is ready when the app calls governed AI (exit 2, AI_NOT_READY, with the IT step), then runs the pipeline's kit gate in the local session and refuses (KIT_GATE_FAILED) what CodeBuild would refuse. promote asks the same AI question for production when --app-root names the app.",
@@ -93,7 +93,10 @@ const LOCAL_COMMANDS = ["init", "dev", "stop", "check", "jobs"];
93
93
  const progress = (message) => { process.stderr.write(`${message}\n`); };
94
94
  /** Envelope for commands that start no Isomorph operation (local kit commands, integrations). */
95
95
  const summaryEnvelope = (result) => ({ schema: "isomorph.cli-result/1.0", cliVersion: CLI_VERSION, status: "SUCCEEDED", operationStarted: false, result });
96
- const emit = (value) => { process.stdout.write(json ? `${JSON.stringify(value)}\n` : renderSummary(value)); process.exitCode = 0; };
96
+ /** `line` is the human form for a command whose result is a sentence, not a summary. */
97
+ const emit = (value, line) => { process.stdout.write(json ? `${JSON.stringify(value)}\n` : line ?? renderSummary(value)); process.exitCode = 0; };
98
+ /** Exit 2, like a usage error: nothing started and the fix is a command the maker runs. */
99
+ const USAGE_REFUSALS = ["INTEGRATIONS_NOT_READY", "AI_NOT_READY", "NOT_A_MEMBER", "TENANT_AMBIGUOUS", "NOT_A_START_LINK", "PLATFORM_UNREACHABLE"];
97
100
  if (command === "--version" || command === "version") {
98
101
  process.stdout.write(`${CLI_VERSION}\n`);
99
102
  }
@@ -133,8 +136,7 @@ else {
133
136
  try {
134
137
  if (command === "connect") {
135
138
  const saved = await connect(connectUrl);
136
- process.stdout.write(`Isomorph is connected for ${saved.tenantId}.\n`);
137
- process.exitCode = 0;
139
+ emit(summaryEnvelope({ tenantId: saved.tenantId, displayName: saved.displayName }), `Connected to ${companyLabel(saved)}.\n`);
138
140
  }
139
141
  else if (LOCAL_COMMANDS.includes(command)) {
140
142
  // Local commands run before the company config/login requirement: the base app needs neither.
@@ -158,7 +160,7 @@ else {
158
160
  const sdk = await ensureSdk(target, bundle, process.env, runCommand, progress);
159
161
  if (sdk === "missing")
160
162
  progress(`The kit SDK was not installed: set ISOMORPH_KIT_SDK_TARBALL to the bundle's ${bundle.sdk.package} tarball (or use a bundle with sdk.url), then rerun \`isomorph init\`.`);
161
- progress(result.mode === "starter" ? "Starter created. Next: `isomorph dev --app-root <path>`. Codex reads the AGENTS.md block; Claude Code reads .claude/skills/isomorph-kit/SKILL.md." : upgrade ? (result.bundleChanges.length ? "Kit bundle upgraded; running checks." : "Kit bundle already current; running checks.") : "Kit files added; existing files were kept.");
163
+ progress(result.mode === "starter" ? "Starter created. Next: `isomorph dev --app-root <path>`. Both agents read the Isomorph block in CLAUDE.md / AGENTS.md and the `isomorph` skill installed by agent-setup." : upgrade ? (result.bundleChanges.length ? "Kit bundle upgraded; running checks." : "Kit bundle already current; running checks.") : "Kit files added; existing files were kept.");
162
164
  if (upgrade) {
163
165
  const report = await runChecks(target, { run: runCommand, bundle, output: progress });
164
166
  emit(summaryEnvelope({ ...result, report }));
@@ -183,7 +185,7 @@ else {
183
185
  let governance;
184
186
  if (testIntegrations) {
185
187
  if (!config)
186
- throw new CliError("CONFIG_REQUIRED", "`--integrations` needs a company connection: run `isomorph connect <work-email-or-start-url>`.");
188
+ throw new CliError("CONFIG_REQUIRED", "`--integrations` needs a company connection: run `isomorph connect <work-email>`.");
187
189
  const token = await companyToken();
188
190
  if (!token)
189
191
  throw new CliError("AUTH_REQUIRED", "`--integrations` needs an Isomorph sign-in: run `isomorph login`.");
@@ -215,13 +217,13 @@ else {
215
217
  else {
216
218
  const config = resolveConfig(process.env, await loadConfig());
217
219
  if (!config)
218
- throw new CliError("CONFIG_REQUIRED", "Connect Isomorph first with `isomorph connect <work-email-or-start-url>`.");
220
+ throw new CliError("CONFIG_REQUIRED", "Connect Isomorph first with `isomorph connect <work-email>`.");
219
221
  const url = config.mcpUrl;
220
222
  const tenant = config.tenantId;
221
223
  if (command === "login") {
222
224
  await login(url, tenant, progress);
223
- process.stdout.write("Isomorph sign-in complete.\n");
224
- process.exitCode = 0;
225
+ const signedIn = await signedInDetails(config);
226
+ emit(summaryEnvelope({ tenantId: tenant, displayName: config.displayName, ...signedIn }), signedIn ? `Signed in to ${companyLabel(config)} as ${signedIn.account}. ${companySystemsLine(signedIn.connections)}\n` : "Isomorph sign-in complete.\n");
225
227
  }
226
228
  else if (command === "logout") {
227
229
  await logout(url, tenant);
@@ -296,8 +298,7 @@ else {
296
298
  process.stderr.write(renderFailure(envelope));
297
299
  if (json || command === "productionise")
298
300
  process.stdout.write(`${JSON.stringify(envelope)}\n`);
299
- // Exit 2, like a usage error: nothing started and the fix is a command the maker runs.
300
- process.exit(envelope.error?.code === "INTEGRATIONS_NOT_READY" || envelope.error?.code === "AI_NOT_READY" ? 2 : 1);
301
+ process.exit(USAGE_REFUSALS.includes(envelope.error?.code ?? "") ? 2 : 1);
301
302
  }
302
303
  }
303
304
  /** One line per (connection, identity): the three lanes are one approval, so they are read together. */
@@ -314,6 +315,22 @@ function renderIntegrationsStatus(status) {
314
315
  function renderRequest(result) {
315
316
  return [`App ${result.appId}: ${result.connection}`, ...result.requests.map(item => ` ${IDENTITY_WORDS[item.identityMode]} — ${item.operations.join(", ")} on ${item.resources.map(requestResourceName).join(", ")}${item.presentation ? ` (posts as "${item.presentation.displayName}")` : ""}: ${item.state} — ${item.summary}`)].join("\n");
316
317
  }
318
+ /**
319
+ * Who signed in and what IT has set up, for the line after `login`; undefined
320
+ * when either cannot be read — the sign-in itself stands either way.
321
+ */
322
+ async function signedInDetails(config) {
323
+ try {
324
+ const token = await refreshStoredToken(config.mcpUrl, config.tenantId);
325
+ const account = token && await connectedAccount(config.mcpUrl, config.tenantId, token);
326
+ if (!account)
327
+ return undefined;
328
+ return { account, ...await integrationsCatalog(new GovernanceClient(config.apiUrl, token, config.tenantId)) };
329
+ }
330
+ catch {
331
+ return undefined;
332
+ }
333
+ }
317
334
  function optionValue(flag) {
318
335
  const index = args.indexOf(flag);
319
336
  const value = index >= 0 ? args[index + 1] : undefined;
@@ -1,6 +1,8 @@
1
1
  import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
  import { homedir } from "node:os";
4
+ import { transportFailure } from "./integrations.js";
5
+ import { CliError } from "./output.js";
4
6
  export function configPath(env = process.env) {
5
7
  return join(env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config"), "isomorph", "config.json");
6
8
  }
@@ -12,7 +14,7 @@ export async function loadConfig(path = configPath()) {
12
14
  catch (error) {
13
15
  if (isMissing(error))
14
16
  return undefined;
15
- throw new Error("Isomorph could not read its saved connection. Run `isomorph connect <start-url>` again.");
17
+ throw new Error("Isomorph could not read its saved connection. Run `isomorph connect <work-email>` again.");
16
18
  }
17
19
  }
18
20
  export async function saveConfig(config, path = configPath()) {
@@ -23,54 +25,91 @@ export async function saveConfig(config, path = configPath()) {
23
25
  await writeFile(path, JSON.stringify(config, null, 2), { mode: 0o600 });
24
26
  await chmod(path, 0o600);
25
27
  }
26
- export async function connect(startUrl, path = configPath()) {
28
+ const EMAIL = /^[^\s@]+@([a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+)$/i;
29
+ /** The platform's refusals of a start lookup, as the CLI codes them; any other refusal is an outage. */
30
+ const REFUSALS = { tenant_unknown: "NOT_A_MEMBER", tenant_ambiguous: "TENANT_AMBIGUOUS" };
31
+ /**
32
+ * Connects to the company a work email belongs to, or to a start link.
33
+ *
34
+ * An email is never guessed at: the platform answers `/start/<domain>.json`
35
+ * from the same policies that admit the person at sign-in — the one company
36
+ * that admits the domain, or a 400 whose `error_description` already names
37
+ * the fix (ask IT, or run the printed command). Before 0.3.0 the CLI took the
38
+ * first domain label as the tenant id, so `fourierlabs.ai` looked for a company
39
+ * `fourierlabs` that does not exist (2026-09-14).
40
+ */
41
+ export async function connect(startUrl, path = configPath(), fetchImpl = fetch) {
27
42
  const input = startUrl.trim();
28
- const email = /^[^\s@]+@([a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+)$/i.exec(input);
29
- const guessedTenant = email?.[1]?.split(".")[0]?.toLowerCase();
43
+ const domain = EMAIL.exec(input)?.[1]?.toLowerCase();
44
+ const url = domain ? new URL(`${platformOrigin()}/start/${domain}.json`) : startLink(input);
45
+ const response = await fetchImpl(url, { headers: { accept: "application/json" }, redirect: "error" })
46
+ .catch((error) => { throw new CliError("PLATFORM_UNREACHABLE", `Isomorph at ${url.host} could not be reached: ${transportFailure(error)}.`); });
47
+ const body = await response.json().catch(() => undefined);
48
+ if (!response.ok && isRecord(body) && typeof body.error_description === "string") {
49
+ // The server's sentence already names the fix. Its links are printed without
50
+ // their scheme: the failure printer strips every full URL (output.ts), and
51
+ // `connect` accepts a link without one. `tenants` (with the full links) rides
52
+ // along as the envelope's result for --json readers.
53
+ throw new CliError(REFUSALS[String(body.error)] ?? "PLATFORM_UNREACHABLE", body.error_description.replace(/https?:\/\//g, ""), undefined, undefined, Array.isArray(body.tenants) ? { tenants: body.tenants } : undefined);
54
+ }
55
+ // The console is a single-page app: any path it does not know is answered with its own page, status 200.
56
+ if (!domain && !isRecord(body) && /text\/html/i.test(response.headers.get("content-type") ?? ""))
57
+ throw new CliError("NOT_A_START_LINK", "That is the Isomorph console, not a connect link. Use your work email (`isomorph connect <email>`) or your company page (platform.isomorph.ai/t/<company>).");
58
+ if (!response.ok || !isRecord(body))
59
+ throw new CliError("PLATFORM_UNREACHABLE", `Isomorph answered ${response.status} instead of a company connection profile. Try again in a minute; if it keeps failing, Isomorph is down.`);
60
+ if (body.schema !== "harbour.tenant-start/1.0")
61
+ throw new Error("This is not an Isomorph connection profile.");
62
+ const config = {
63
+ schema: "isomorph.cli-config/1.0",
64
+ tenantId: String(body.tenantId ?? ""),
65
+ ...(typeof body.displayName === "string" ? { displayName: body.displayName } : {}),
66
+ mcpUrl: String(body.mcpUrl ?? ""),
67
+ ...(typeof body.uploadUrl === "string" ? { uploadUrl: body.uploadUrl } : {}),
68
+ ...(typeof body.apiUrl === "string" ? { apiUrl: body.apiUrl } : {})
69
+ };
70
+ if (!validConfig(config))
71
+ throw new Error("This is not the expected Isomorph connection profile.");
72
+ await saveConfig(config, path);
73
+ return config;
74
+ }
75
+ function platformOrigin() {
76
+ return process.env.ISOMORPH_PLATFORM_URL?.trim() || "https://platform.isomorph.ai";
77
+ }
78
+ /**
79
+ * The link as the person typed it, made fetchable: the console's company page
80
+ * (`/t/<company>`) is a start link too, a link without its scheme (as a browser
81
+ * bar or a printed refusal shows it) is one too, and the platform only answers
82
+ * over https — the plain `http://` form used to fail on the redirect.
83
+ */
84
+ function startLink(input) {
30
85
  let url;
31
86
  try {
32
- url = new URL(guessedTenant ? `https://platform.isomorph.ai/start/${guessedTenant}.json` : input);
33
- }
34
- catch {
35
- throw new Error("Enter your work email or the company setup link from your Isomorph start page: `isomorph connect <work-email-or-start-url>`.");
87
+ url = new URL(/^[a-z][a-z0-9+.-]*:/i.test(input) ? input : `https://${input}`);
36
88
  }
89
+ catch { /* not a link at all */ }
90
+ if (!url?.hostname.includes("."))
91
+ throw new Error("Enter your work email or the company setup link from your Isomorph start page: `isomorph connect <work-email | company-start-url>`.");
37
92
  if (!/^https?:$/.test(url.protocol))
38
93
  throw new Error("The Isomorph connection URL must be an HTTPS URL.");
39
- if (!url.pathname.endsWith(".json")) {
94
+ url.protocol = "https:";
95
+ const consolePage = /^\/t\/([a-z0-9][a-z0-9_-]*)\/?$/i.exec(url.pathname);
96
+ if (consolePage)
97
+ url.pathname = `/start/${consolePage[1].toLowerCase()}`;
98
+ if (!url.pathname.endsWith(".json"))
40
99
  url.pathname = `${url.pathname.replace(/\/$/, "")}.json`;
41
- }
42
- let config;
43
- try {
44
- const response = await fetch(url, { headers: { accept: "application/json" }, redirect: "error" });
45
- if (!response.ok)
46
- throw new Error("Isomorph could not read this company connection profile.");
47
- const body = await response.json();
48
- if (!isRecord(body) || body.schema !== "harbour.tenant-start/1.0")
49
- throw new Error("This is not an Isomorph connection profile.");
50
- config = {
51
- schema: "isomorph.cli-config/1.0",
52
- tenantId: String(body.tenantId ?? ""),
53
- mcpUrl: String(body.mcpUrl ?? ""),
54
- ...(typeof body.uploadUrl === "string" ? { uploadUrl: body.uploadUrl } : {}),
55
- ...(typeof body.apiUrl === "string" ? { apiUrl: body.apiUrl } : {})
56
- };
57
- if (!validConfig(config) || (guessedTenant && config.tenantId !== guessedTenant))
58
- throw new Error("This is not the expected Isomorph connection profile.");
59
- }
60
- catch (error) {
61
- if (guessedTenant)
62
- throw new Error("Isomorph could not find your company from that email. Ask for the company setup link from your Isomorph start page, then run `isomorph connect <company-start-url>`.");
63
- throw error;
64
- }
65
- await saveConfig(config, path);
66
- return config;
100
+ return url;
67
101
  }
68
102
  export function resolveConfig(env, saved) {
69
103
  const mcpUrl = env.ISOMORPH_MCP_URL?.trim() || saved?.mcpUrl;
70
104
  const tenantId = env.ISOMORPH_TENANT?.trim() || saved?.tenantId;
71
105
  if (!mcpUrl || !tenantId)
72
106
  return undefined;
73
- return { schema: "isomorph.cli-config/1.0", tenantId, mcpUrl, ...(saved?.uploadUrl ? { uploadUrl: saved.uploadUrl } : {}), apiUrl: env.ISOMORPH_API_URL?.trim() || saved?.apiUrl || apiUrlFromMcpUrl(mcpUrl) };
107
+ // The saved name belongs to the saved tenant; an ISOMORPH_TENANT override names another company.
108
+ return { schema: "isomorph.cli-config/1.0", tenantId, ...(saved?.displayName && tenantId === saved.tenantId ? { displayName: saved.displayName } : {}), mcpUrl, ...(saved?.uploadUrl ? { uploadUrl: saved.uploadUrl } : {}), apiUrl: env.ISOMORPH_API_URL?.trim() || saved?.apiUrl || apiUrlFromMcpUrl(mcpUrl) };
109
+ }
110
+ /** The company as the CLI names it: `Fourier (fourier)`, or the id twice when the profile carried no name. */
111
+ export function companyLabel(config) {
112
+ return `${config.displayName ?? config.tenantId} (${config.tenantId})`;
74
113
  }
75
114
  /** `https://host/stage/mcp` -> `https://host/stage`: the dev routes live beside the MCP endpoint. */
76
115
  export function apiUrlFromMcpUrl(mcpUrl) {
@@ -84,6 +123,7 @@ function validConfig(value) {
84
123
  && value.schema === "isomorph.cli-config/1.0"
85
124
  && typeof value.tenantId === "string"
86
125
  && value.tenantId.length > 0
126
+ && (value.displayName === undefined || typeof value.displayName === "string")
87
127
  && typeof value.mcpUrl === "string"
88
128
  && /^https?:\/\//.test(value.mcpUrl)
89
129
  && (value.uploadUrl === undefined || typeof value.uploadUrl === "string")
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The kit's rulebook, in four modules. `isomorph agent-setup` writes each one as
3
+ * `<name>.md` beside the user-level `isomorph` skill (Claude Code and Codex),
4
+ * so the rules track the installed CLI the way the skill does; every other surface
5
+ * (the skill itself, the managed block in CLAUDE.md / AGENTS.md, the starter's
6
+ * README and source comments, the gate's refusals) points here instead of restating
7
+ * a rule. `core.md` is read before the first edit of any app; the other three are
8
+ * loaded only by an app that declares the thing they govern. Budgets and the
9
+ * no-duplication rule are enforced by tests/cli-guide-budget.test.ts.
10
+ */
11
+ export const GUIDE_MODULES = {
12
+ core: `# Isomorph kit — core rules
13
+
14
+ ## The SDK is the only door
15
+
16
+ \`@isomorph.ai/app-sdk\`, on the client exported by \`src/isomorph.client.ts\`, is the whole path: \`isomorph.identity.current()\`, \`isomorph.data.from(table)\`, \`isomorph.files.*\`, \`isomorph.realtime.*\`, \`isomorph.integrations.execute\` (integrations.md) and \`isomorph.ai.chat\` (ai.md). Never open a database, bucket or company URL from browser code; never add another backend, auth library, deployment config, provider key or SDK. No secrets, tokens, \`.env\` values or fetched company content in source. Commit \`.isomorph/integrations.json\` and \`.isomorph/kit.lock.json\`, never \`.isomorph/local/\`.
17
+
18
+ ## Identity
19
+
20
+ Isomorph SSO signs everyone in: no login forms, no roles or ids trusted from the browser, no public routes or wildcard exceptions. \`isomorph.identity.current()\` answers \`{ id, email }\` only — no display name; derive one from the email.
21
+
22
+ ## Data (\`core.rls-owner-scoped\`)
23
+
24
+ Schema changes are SQL files in \`migrations/\`, applied by \`isomorph dev\` and \`isomorph check\`. Every table: \`ENABLE ROW LEVEL SECURITY\`, a policy, and \`GRANT\` of every verb a policy allows to \`harbour_app_gateway\` and no other role; the database gate names whatever breaks this. Row ownership is decided in SQL through \`current_setting('harbour.user_id', true)\` (set by the gateway per request, with \`harbour.user_email\`), never in the browser. Two patterns:
25
+
26
+ - **Private table** — every policy scoped to \`current_setting('harbour.user_id', true)\`; the gate then asserts a second signed-in person cannot read, update or delete another person's row.
27
+ - **Shared-read, owner-write** — \`USING (true)\` for SELECT; INSERT, UPDATE and DELETE scoped to the identity setting.
28
+
29
+ The gate treats a table as private when any of its policies mentions \`harbour.user_id\` (or \`harbour.user_email\`), so a shared-read table must keep that setting out of its SELECT policy, or its cross-user read check fails.
30
+
31
+ ## Files
32
+
33
+ \`isomorph.files.upload(path, file)\`, \`list(prefix)\` and \`remove([path])\`.
34
+
35
+ ## Realtime
36
+
37
+ \`isomorph.realtime.channel(name).on("postgres_changes", { event: "*" | "INSERT" | "UPDATE" | "DELETE", schema: "public", table }, payload => …).subscribe()\`; the payload carries \`eventType\`, \`new\` and \`old\`.
38
+
39
+ ## Retained checks (\`core.journey-fk\`)
40
+
41
+ \`.isomorph/checks/\` holds one journey per capability the app's code uses plus a cross-user denial per private table. \`isomorph check\` generates them from \`migrations/\` and \`src/\` and deletes the ones the app no longer needs, so run it in the same edit that changes the app; never write or remove these files by hand. A generated check starts with \`// isomorph:generated\` and a digest of its body; edit one and it is yours — kept, no longer updated, never deleted — so delete it yourself in the same edit that removes the feature it covers. The pairing is two-way: the \`flow\` gate refuses a capability no check exercises and a check exercising something the code no longer does (\`flow.check-failed\`) — the most common reason a first deploy is refused. What cannot be generated (\`actions\`, \`realtime\`, a table with no migration) is named for you to write. No journey is generated for a table whose row needs a parent (a foreign key): write that table's journey yourself.
42
+
43
+ ## Commands
44
+
45
+ - \`isomorph dev --app-root .\` — local Postgres, storage, gateway and Vite behind one loopback origin; company calls use the \`isomorph login\` account.
46
+ - \`isomorph check --app-root . --json\` — types, build, then the pipeline's own kit gate locally (declaration, database gate, write probe with cross-user denial, journeys, operation coverage); report in \`.isomorph/local/check-report.json\`.
47
+ - \`isomorph productionise --app-root . …\` — saves and deploys the private preview; \`isomorph promote\` after the person has tried it.
48
+
49
+ ## Error codes
50
+
51
+ - \`CONFIG_REQUIRED\`: no company connected — \`isomorph connect <work-email>\`.
52
+ - \`AUTH_REQUIRED\`: sign in — \`isomorph login\`.
53
+ - \`NOT_A_MEMBER\` / \`TENANT_AMBIGUOUS\`: no company, or more than one, admits that email domain — use the command it printed or IT's link.
54
+ - \`CHECKS_FAILED\` / \`CHECKS_STALE\`: fix what \`isomorph check\` reports and run it again on this exact code.
55
+ - \`APP_NOT_FOUND\`: the app is linked to another company — connect back to it with its start link, or clear \`appId\` and \`tenantId\` in \`.isomorph/kit.lock.json\` to relink.
56
+ - \`INTEGRATIONS_NOT_READY\`: IT has not approved a declared connection yet (integrations.md).
57
+ - \`AI_NOT_READY\`: IT has not enabled the company's AI setup yet (ai.md).`,
58
+ integrations: `# Company systems (Slack, Gmail, warehouse)
59
+
60
+ ## Declare
61
+
62
+ Reach company systems only through \`isomorph.integrations.execute(connection, { operation, resource, input })\`, declared in \`.isomorph/integrations.json\`. Operations, a closed set: \`slack.channel.history\` (user identity), \`slack.message.post\` (app or user identity, declared per app), \`gmail.thread.list\`, \`gmail.message.read\` and \`gmail.message.send\` (user identity, resource \`inbox\`, plain-text send), \`warehouse.view.read\` (app identity). Resources are logical names, never IDs, URLs or tokens. The file starts as \`"connections": {}\`: declare a connection only when the app really calls it, by the identifier the catalog lists, with only the operations it calls — each declared connection blocks the deploy until IT grants it, and an undeclared one cannot be requested (\`CONNECTION_NOT_DECLARED\`). Strict JSON, no comments.
63
+
64
+ \`\`\`json
65
+ "company-slack": { "kind": "saas", "operations": { "slack.message.post": { "identity": "app", "resources": ["team-updates"] } } },
66
+ "sales-warehouse": { "kind": "database", "operations": { "warehouse.view.read": { "identity": "app", "resources": { "weekly_sales": { "columns": ["week", "total"] } } } } }
67
+ \`\`\`
68
+
69
+ ## Catalog
70
+
71
+ 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 \`isomorph integrations catalog --app-root . --json\`, select a listed table or view, and copy its listed column names exactly; if the object or column is absent, report that catalog gap instead of substituting a plausible name. A resource whose catalog columns are \`["*"]\` is declared and read with \`["*"]\`: inspect the returned row keys; do not invent a schema or ask IT for one.
72
+
73
+ ## Request
74
+
75
+ One request per connection; IT approves it once for every environment. \`isomorph dev\` and \`isomorph productionise\` file it for you; \`isomorph integrations request <connection> --reason "<what the app does with it>" --app-root . --json\` files it now — submit the request yourself in the same turn, and never tell the person to ask IT before you have submitted the request.
76
+
77
+ ## Status
78
+
79
+ READY: use it now. PENDING: say "IT has to approve this; the app works without it until then" and check later with \`isomorph integrations status --app-root . --json\`. Before a real integration test, read that status; show pending IT approval and missing consent separately, and test ready destinations independently. Retained checks that call \`isomorph.integrations.execute\` get the gate's fixture, locally and in the pipeline alike (nothing sent or read; the report says so); only \`isomorph check --integrations\` (reads) exercises real access.
80
+
81
+ ## Call shape
82
+
83
+ On the exported client (\`src/isomorph.client.ts\`), always this exact shape and never a wrapper function (nor a generic string/unknown wrapper): the kit gate derives what the app uses from the direct call.
84
+
85
+ \`\`\`ts
86
+ const report = await isomorph.integrations.execute("sales-warehouse", {
87
+ operation: "warehouse.view.read", resource: "weekly_sales", input: { columns: ["week", "total"], limit: 100 }
88
+ });
89
+ \`\`\`
90
+
91
+ Input bounds: \`slack.channel.history\` \`input.limit\` 1..15, \`gmail.thread.list\` \`input.limit\` 1..15, \`warehouse.view.read\` \`input.limit\` 1..1000 (\`VIEW_READ_MAX_LIMIT\`); larger is refused with \`INPUT_INVALID\`, so page instead.
92
+
93
+ ## Slack modes
94
+
95
+ \`"identity": "app"\` posts as the company's Slack bot, Isomorph AI, under the connection's \`presentation.displayName\` (optional \`iconEmoji\`), which IT approves — leave it out to post as Isomorph AI itself; every app-mode post ends "Posted by <app> on Isomorph". \`"identity": "user"\` posts as the person after their consent; an older consent answers \`USER_RECONNECT_REQUIRED\` — offer Connect again. Never pretend one is the other. The declaration is the mode requested; the call names the approved mode it runs under (\`mode: "app"\` or \`"user"\`): optional with one approved mode, required with both (\`MODE_REQUIRED\`), and an unapproved mode is \`MODE_NOT_GRANTED\`, never swapped. \`RESOURCE_NOT_APPROVED\` on a post: the bot is not in the channel — say "IT (or anyone in the channel) has to run \`/invite @Isomorph AI\` in #<channel> first". On a request: the resource is not on the connection yet — IT adds it in the console under Controls & integrations, then you run the same request again.
96
+
97
+ ## Consent
98
+
99
+ Only user-identity operations (\`slack.channel.history\`, \`gmail.thread.list\`, \`gmail.message.read\`, \`gmail.message.send\`, and \`slack.message.post\` declared \`"identity": "user"\`) need it; build the flow in unasked, during the original implementation: the operation's control calls \`isomorph.integrations.connect(connection)\`, follows \`authorizationUrl\` when the result is \`consent_required\`, returns to a clear connected state and lets the person continue. Consent starts only from that person's control, never on page load; missing consent never falls back to another account. App-identity operations (\`slack.message.post\` declared \`"identity": "app"\`, \`warehouse.view.read\`) never call connect — it is refused as unapproved user access and puts nothing in IT's queue.
100
+
101
+ ## Sends
102
+
103
+ A send (\`slack.message.post\`, \`gmail.message.send\`) runs only from an explicit Send control — pressed by the person, or by you for an explicitly authorized test — with a fresh UUID \`idempotencyKey\` per press (reused only to retry it); never from an effect, a browser timer or a check. After a partial send, retry only the failed destination with its original key. Scheduled sends: jobs.md.`,
104
+ ai: `# Governed AI
105
+
106
+ AI goes through \`isomorph.ai\` only: one \`isomorph.ai.chat({ messages, maxTokens })\` call on the app's one client (\`src/isomorph.client.ts\`), never through a wrapper function (the gate reads only the direct call), behind a control the person presses — never on load, in an effect or a timer. Never add an OpenAI/Anthropic/Gemini key, SDK or URL: the governed AI gateway holds the key and IT sees every call. 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 not enabled an AI provider yet: say so in one line and keep the app working without AI.
107
+
108
+ \`\`\`ts
109
+ const reply = await isomorph.ai.chat({ messages: [{ role: "user", content: \`Summarise these notes in three lines:\\n\${notes.map(n => n.title).join("\\n")}\` }], maxTokens: 200 });
110
+ \`\`\`
111
+
112
+ The starter calls no AI; add the one call when the person asks. \`isomorph check\` writes \`.isomorph/checks/ai-journey.mjs\` for it (and deletes it when the call goes): locally the journey makes a real governed call through the development route with your \`isomorph login\`; in the pipeline it is answered by a canned completion and reported as not tested.`,
113
+ jobs: `# Scheduled jobs
114
+
115
+ Scheduled work lives only in \`jobs/<name>.ts\`: export one literal UTC cron as \`schedule\` and one default async handler. Never use \`setInterval\`, an effect or a browser timer as a scheduler; Isomorph runs the same declaration automatically after deployment, and the deployed Kubernetes schedule owns the real clock. Test it immediately: start \`isomorph dev\`, then \`isomorph jobs run <name> --app-root . --scheduled-at <matching-UTC-time> --json\` — local data and safe fixtures, nothing sent. Use \`--real\` only when the person explicitly asks to test the company action now and the exact action has development approval.
116
+
117
+ The handler acts as the app, so only app-mode operations (\`slack.message.post\` declared \`"identity": "app"\`) may run there; user-mode Slack and Gmail need a person and cannot. A real scheduled send requires the person's explicit request, the exact operation and destination declared in \`.isomorph/integrations.json\`, and a grant for that environment; use one deterministic idempotency key for the business period and destination so a replay does not silently repost.
118
+
119
+ Tell them: "Your scheduled task is ready. I can test the app on your computer now. Company messages will start after the app is online and access is approved."`
120
+ };
@@ -55,7 +55,7 @@ export class GovernanceClient {
55
55
  * `fetch failed` and nothing else, so what was wrong — and that nothing about the
56
56
  * app was — could not be told from the output.
57
57
  */
58
- function transportFailure(error) {
58
+ export function transportFailure(error) {
59
59
  const cause = (error instanceof Error ? error.cause ?? error : error);
60
60
  const words = (failure) => { const { message } = (failure ?? {}); return typeof message === "string" ? message.trim() : ""; };
61
61
  const text = words(cause) || (Array.isArray(cause?.errors) ? cause.errors.map(words).filter(Boolean).join("; ") : "") || String(cause);
@@ -359,6 +359,11 @@ export async function integrationsCatalog(client) {
359
359
  }
360
360
  const PROVIDER_WORDS = { slack: "Slack", gmail: "Gmail" };
361
361
  const INSTALLATION_WORDS = { installed: "bot installed", not_installed: "bot not installed yet: IT installs it in the Isomorph console", reconnect_required: "bot needs reconnecting: IT does that in the Isomorph console" };
362
+ /** What IT has set up, for the line after `login`: `IT has set up: Company Slack (bot installed), Gmail.` — or that nothing is, and that the app works without it. */
363
+ export function companySystemsLine(connections) {
364
+ const systems = connections.map(entry => `${entry.displayName}${entry.installation && INSTALLATION_WORDS[entry.installation] ? ` (${INSTALLATION_WORDS[entry.installation]})` : ""}`).join(", ");
365
+ return systems ? `IT has set up: ${systems}.` : "IT has not set up any company systems here yet (Slack, Gmail, warehouse); the app works without them.";
366
+ }
362
367
  /** One block per connection: the identifier to declare, what it is, the allowed operations with their identity, and the approved names per environment. */
363
368
  export function renderIntegrationsCatalog(result) {
364
369
  if (!result.connections.length)
@@ -1,6 +1,6 @@
1
1
  export const PUBLISHED_KIT_BUNDLE = {
2
2
  "schema": "isomorph.kit-bundle/1.0",
3
- "kitVersion": "0.2.0",
3
+ "kitVersion": "0.3.0",
4
4
  "sdk": {
5
5
  "package": "@isomorph.ai/app-sdk",
6
6
  "version": "1.2.0",
@@ -8,25 +8,25 @@ export const PUBLISHED_KIT_BUNDLE = {
8
8
  "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:6b41494883215527a73e05a1e07d022552dfe3402915e23950e357e5e9381c49"
9
9
  },
10
10
  "images": {
11
- "appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:13c51a2396d1af396e744b1f6f09abce9fd54267418b07122af89d6797fd5809",
12
- "sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:7c06fe3d14f902eef93240f33a6c39e20b01459c108dcaa7bdaa892109f6853d"
11
+ "appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:cdb494a98ddcab21a42b9cb8089d077007da1d4f178458cef94b3aa9ad6e1a11",
12
+ "sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:34d5e175a6346636a25fe8c6f9b730e93d67ef1e3361b0349c31f2a2da3322a8"
13
13
  },
14
14
  "nativeRuntime": {
15
15
  "darwinArm64": {
16
- "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:217471d3f95de27f774da7765b6e01f814f770c531c4ae2e0f17474b2b909ada",
17
- "sha256": "217471d3f95de27f774da7765b6e01f814f770c531c4ae2e0f17474b2b909ada"
16
+ "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:a07e18b61ca9b5db8077fd37ced21ca29bc4e37049f10724c61cea5a68d46077",
17
+ "sha256": "a07e18b61ca9b5db8077fd37ced21ca29bc4e37049f10724c61cea5a68d46077"
18
18
  },
19
19
  "linuxX64": {
20
- "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:a55f40569ae51d6a4088b8025ea8f720ef6876a352fe3f17b32ff9571098ffd8",
21
- "sha256": "a55f40569ae51d6a4088b8025ea8f720ef6876a352fe3f17b32ff9571098ffd8"
20
+ "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:43b80fbe2153c79ccca6b663d79e32e100f56b6ada896f0bb192cfd23bbfc1cc",
21
+ "sha256": "43b80fbe2153c79ccca6b663d79e32e100f56b6ada896f0bb192cfd23bbfc1cc"
22
22
  },
23
23
  "windowsX64": {
24
- "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:3f637182209f6063b85d781bddcf9b437c4bca7f3361df37e209f874a377aaa1",
25
- "sha256": "3f637182209f6063b85d781bddcf9b437c4bca7f3361df37e209f874a377aaa1"
24
+ "url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:540020f44f874d20c28fbd19b8879aab9f3e41aa2d228f9b5b7bc301ac428afd",
25
+ "sha256": "540020f44f874d20c28fbd19b8879aab9f3e41aa2d228f9b5b7bc301ac428afd"
26
26
  }
27
27
  },
28
28
  "brief": {
29
- "fingerprint": "dd6aca0163116c0c5b73ca447094aba67c5ca58a9d285722acb12eef5fde4e9e"
29
+ "fingerprint": "e1bd2932cd6b9f521734877d6b225ea609408bf12b5ff2d84461e585baa10e80"
30
30
  },
31
31
  "declarationSchema": "isomorph.app-integrations/2.0"
32
32
  };
@@ -140,7 +140,7 @@ export async function planRetainedChecks(root, schema, declared) {
140
140
  continue;
141
141
  const generic = capability === "files" ? filesJourney(files) : capability === "telemetry" ? telemetryJourney(files) : capability === "ai" ? aiJourney(files) : undefined;
142
142
  if (!generic) {
143
- plan.blocked.push(`the \`${capability}\` capability ${files.join(", ")} uses: Isomorph writes journeys for data, files, telemetry and ai only — write \`.isomorph/checks/${capability}-journey.mjs\` yourself`);
143
+ plan.blocked.push(`the \`${capability}\` capability ${files.join(", ")} uses: Isomorph writes journeys for data, files, telemetry and ai only — write \`.isomorph/checks/${capability}-journey.mjs\` yourself${capability === "realtime" ? " (the filter and payload shape: core.md in the isomorph skill)" : ""}`);
144
144
  continue;
145
145
  }
146
146
  add(capability, `${capability}-journey.mjs`, generic);
@@ -254,7 +254,7 @@ function crossUserCheck(table, schema, row, sources) {
254
254
  "",
255
255
  "// 1. SELECT by key as the second person: no row.",
256
256
  `const { data: seen } = await other.data.from("${table}").select("*").eq("${row.locator}", row.${row.locator});`,
257
- `assert.equal(seen.length, 0, "${table}: a second signed-in user can read another user's row — the owner policy does not isolate users");`,
257
+ `assert.equal(seen.length, 0, "${table}: a second signed-in user can read another user's row — the owner policy does not isolate users — rule core.rls-owner-scoped (core.md in the isomorph skill)");`,
258
258
  "",
259
259
  "// 2. UPDATE and 3. DELETE by key as the second person: refused (403) or no row affected.",
260
260
  "const denied = async (verb, operation) => {",
@@ -331,7 +331,7 @@ function rowShape(schema, needsUpdate) {
331
331
  const required = writable.filter(column => column.notNull && !column.hasDefault);
332
332
  const unwritable = required.find(column => column.references) ?? required.find(column => !fillExpression(column));
333
333
  if (unwritable)
334
- return { blocked: `its \`${unwritable.name} ${unwritable.declaredType}\` column ${unwritable.references ? "references another table, so a row cannot be written without inventing its parent" : "has a type Isomorph cannot invent a value for"}` };
334
+ return { blocked: `its \`${unwritable.name} ${unwritable.declaredType}\` column ${unwritable.references ? "references another table, so a row cannot be written without inventing its parent — rule core.journey-fk (core.md in the isomorph skill)" : "has a type Isomorph cannot invent a value for"}` };
335
335
  const marker = writable.find(column => isMarker(column) && column.notNull && !column.hasDefault) ?? writable.find(isMarker);
336
336
  if (!marker)
337
337
  return { blocked: "it has no free text or number column a check could write a unique value into and find the row by" };
@@ -9,8 +9,9 @@ export { MANAGED_END, MANAGED_START };
9
9
  /**
10
10
  * Creates the starter in an empty directory, or adds the missing kit files to
11
11
  * an existing Vite + React app. User files are never overwritten: a path that
12
- * exists is reported as kept. Instruction files get a managed block appended, and
13
- * the user-level agent guide is installed so the agents know the kit from any folder.
12
+ * exists is reported as kept. Instruction files get a managed block that points at the
13
+ * rules, and the user-level agent guide (the rules themselves) is installed so the
14
+ * agents know the kit from any folder.
14
15
  */
15
16
  export async function initKit(root, bundle, options = {}) {
16
17
  await mkdir(root, { recursive: true });
@@ -47,7 +48,6 @@ export async function initKit(root, bundle, options = {}) {
47
48
  await appendManaged(root, ".gitignore", GITIGNORE_LINES, result, "\n");
48
49
  for (const file of ["CLAUDE.md", "AGENTS.md"])
49
50
  await appendManaged(root, file, managedBlock(), result, "\n\n", MANAGED_START, MANAGED_END);
50
- await write(".claude/skills/isomorph-kit/SKILL.md", SKILL_FILE);
51
51
  const previous = await readKitLock(root);
52
52
  if (!previous) {
53
53
  await writeKitLock(root, newKitLock(bundle, options.tenantId ?? ""));
@@ -76,9 +76,16 @@ const LEGACY = {
76
76
  end: "<!-- harbour:kit:end -->",
77
77
  generatedPrefix: "// harbour:generated sha256:",
78
78
  gitignoreLine: ".harbour/local/",
79
- skill: ".claude/skills/harbour-kit/SKILL.md",
80
- /** How every release's project skill opened, under the frontmatter `name: harbour-kit`; the body below it changed from release to release. */
81
- skillOpening: 'Follow the "Harbour development kit" block',
79
+ /**
80
+ * The project skills older CLIs wrote — `harbour-kit` up to 0.1.x, `isomorph-kit` up
81
+ * to 0.2.2 — each under one frontmatter name and one opening line, with a body that
82
+ * changed from release to release. Since 0.3.0 the rules live beside the user-level
83
+ * skill and no project skill is written.
84
+ */
85
+ skills: [
86
+ { path: ".claude/skills/harbour-kit/SKILL.md", frontmatter: "name: harbour-kit", opening: 'Follow the "Harbour development kit" block' },
87
+ { path: ".claude/skills/isomorph-kit/SKILL.md", frontmatter: "name: isomorph-kit", opening: 'Follow the "Isomorph development kit" block' }
88
+ ],
82
89
  sdkPackage: "@harbour/app-sdk",
83
90
  /** The SDK's exported type names, renamed with the package. */
84
91
  typeNames: /\bHarbour(User|ErrorCategory|Error|Client|Result|CompatSource)\b/g,
@@ -144,32 +151,35 @@ async function migrateLegacyKit(root, bundle, result) {
144
151
  }
145
152
  }
146
153
  /**
147
- * Deletes the project skill an older CLI wrote, on every `--upgrade` — an app the
148
- * layout migration moved under 0.2.0 still has it beside the new skill. The file
149
- * is the kit's by shape, not by bytes: each release wrote a different body under
150
- * the same frontmatter name and the same opening line, so one whose body an
151
- * author rewrote is kept, and reported, as theirs.
154
+ * Deletes the project skills older CLIs wrote, on every `--upgrade` — an app the
155
+ * layout migration moved under 0.2.0 still has the old one, and every app set up
156
+ * before 0.3.0 has the `isomorph-kit` one. A file is the kit's by shape, not by
157
+ * bytes: each release wrote a different body under the same frontmatter name and
158
+ * the same opening line, so one whose body an author rewrote is kept, and
159
+ * reported, as theirs.
152
160
  */
153
161
  async function retireLegacySkill(root, result) {
154
- const path = join(root, LEGACY.skill);
155
- const text = await readFile(path, "utf8").catch(() => undefined);
156
- if (text === undefined)
157
- return;
158
- if (!isLegacyKitSkill(text)) {
159
- result.kept.push(LEGACY.skill);
160
- return;
162
+ for (const skill of LEGACY.skills) {
163
+ const path = join(root, skill.path);
164
+ const text = await readFile(path, "utf8").catch(() => undefined);
165
+ if (text === undefined)
166
+ continue;
167
+ if (!isLegacyKitSkill(text, skill)) {
168
+ result.kept.push(skill.path);
169
+ continue;
170
+ }
171
+ await rm(path);
172
+ await rmdir(dirname(path)).catch(() => undefined);
173
+ result.updated.push(skill.path);
161
174
  }
162
- await rm(path);
163
- await rmdir(dirname(path)).catch(() => undefined);
164
- result.updated.push(LEGACY.skill);
165
175
  }
166
- /** Frontmatter naming `harbour-kit`, and the opening every release wrote as the body's first line. */
167
- function isLegacyKitSkill(text) {
176
+ /** Frontmatter naming the kit's skill, and the opening every release wrote as the body's first line. */
177
+ function isLegacyKitSkill(text, skill) {
168
178
  const lines = text.split(/\r?\n/);
169
179
  const close = lines.indexOf("---", 1);
170
180
  if (lines[0] !== "---" || close < 0)
171
181
  return false;
172
- return lines.slice(1, close).includes("name: harbour-kit") && (lines.slice(close + 1).find(line => line.trim()) ?? "").startsWith(LEGACY.skillOpening);
182
+ return lines.slice(1, close).includes(skill.frontmatter) && (lines.slice(close + 1).find(line => line.trim()) ?? "").startsWith(skill.opening);
173
183
  }
174
184
  /**
175
185
  * Rewrites what the old kit named in the app's own files — `package.json`,
@@ -236,48 +246,26 @@ const exists = (path) => stat(path).then(() => true, () => false);
236
246
  const readJson = (path) => readFile(path, "utf8").then(text => JSON.parse(text), () => undefined);
237
247
  // ---- Templates -----------------------------------------------------------------
238
248
  const GITIGNORE_LINES = ["node_modules/", "dist/", ".isomorph/local/"].join("\n");
239
- /** The per-app rules, condensed from the pipeline's own briefs (browser SDK, route auth policy, company SSO) for a kit app. */
249
+ /**
250
+ * The per-app block: it says where the rules are and when to read each, and nothing
251
+ * else — the rules themselves live in the modules `agent-setup` writes beside the
252
+ * `isomorph` skill (`guide.ts`), so this block never drifts from them.
253
+ */
240
254
  export function managedBlock() {
241
255
  return [
242
256
  MANAGED_START,
243
257
  "## Isomorph development kit",
244
258
  "",
245
- "This app runs on Isomorph. Keep these rules; `isomorph check` and the deployment pipeline enforce them.",
259
+ "This app runs on Isomorph. Its rules live in the `isomorph` skill (Claude Code: `~/.claude/skills/isomorph/`, Codex: `~/.codex/skills/isomorph/`); if that skill is not installed, run `npx -y @isomorph.ai/cli agent-setup`.",
246
260
  "",
247
- "- Identity, data and files go through `@isomorph.ai/app-sdk` only: `isomorph.identity.current()`, `isomorph.data.from(table)`, `isomorph.files.*`. Never open a database, storage bucket or company system from browser code, and never `fetch` a company URL directly.",
248
- "- Company systems (Slack, Gmail, warehouse views) are reached only through `isomorph.integrations.execute(connection, {operation, resource, input})` with the connection and operation declared in `.isomorph/integrations.json`. Operations are a closed set: `slack.channel.history` (user identity), `slack.message.post` (declared per app: `app` posts as the company's Slack bot, Isomorph AI, under the name IT approved; `user` posts as the signed-in person after their consent), `gmail.thread.list`, `gmail.message.read` and `gmail.message.send` (user identity — reads and one plain-text send as the signed-in person, resource `inbox`), `warehouse.view.read` (app identity). Resources are logical names, never IDs, URLs or tokens.",
249
- "- `.isomorph/integrations.json` starts with `\"connections\": {}` and stays that way until the app really calls a company system. Adding one is two steps: declare the connection — by the identifier `isomorph integrations catalog --app-root .` lists, with only the operations the app calls and only channels, views and mailboxes the catalog shows as approved; never a guessed name — then ask for access — one request per connection, which IT approves once for every environment (development, preview and production together): `isomorph dev` and `isomorph productionise` file it for you, and `isomorph integrations request <connection> --reason \"<why>\" --app-root .` files it now. Every declared connection blocks the deploy until IT grants it, so a connection the app does not call is a deploy that never happens; a connection that is not declared cannot be requested at all (`CONNECTION_NOT_DECLARED`), so it never gets a grant. Before a real integration test, read `isomorph integrations status --app-root . --json`: show pending IT approval separately from personal consent, and test ready destinations independently. After a partial send, retry only the failed destination with its original idempotency key; do not regenerate or resend a successful destination. The file is strict JSON and cannot hold comments; the worked Slack and warehouse examples are in README.md and in the comment in `src/App.tsx`.",
250
- "- Scheduled work lives only in `jobs/<name>.ts`: export one literal UTC cron as `schedule` and one default async handler. Test it immediately with `isomorph jobs run <name> --app-root . --scheduled-at <UTC> --json`; this uses local data and safe fixtures. If the person explicitly asks for a company-action test, use `--real` only after the exact action has development approval. Tell them: \"Your scheduled task is ready. I can test the app on your computer now. Company messages will start after the app is online and access is approved.\" Isomorph runs the same declaration automatically after deployment; never use `setInterval`, an effect or a browser timer as a scheduler.",
251
- "- In browser code, a Slack message is sent only after the person presses an explicit Send control; pass a fresh UUID `idempotencyKey` per send action and reuse the same key when retrying that action. Never send from an effect or browser timer, and never send during checks. A `jobs/*.ts` handler is the only scheduled-send path and acts as the app: only an app-mode operation may run there, after the person explicitly requested it and IT granted its exact destination for that environment. Use a deterministic idempotency key for the business period and destination; local job runs and checks use fixtures and send nothing.",
252
- "- 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.",
253
- "- Consent is only for user-identity operations (`slack.channel.history`, `gmail.*`, and `slack.message.post` declared `\"identity\": \"user\"`): call `isomorph.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.",
254
- "- A retained check that calls `isomorph.integrations.execute` is answered, under `isomorph 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 `isomorph check --integrations` (reads) and the preview's own smoke test.",
255
- "- AI goes through `isomorph.ai` only — `isomorph.ai.chat({ messages, maxTokens })` on the client exported by `src/isomorph.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 `isomorph check` writes `.isomorph/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.",
256
- "- Authentication is owned by Isomorph 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)`.",
257
- "- Every route needs a signed-in human by default; do not add public routes or wildcard exceptions to make something work.",
258
- "- No secrets, tokens, `.env` values or fetched company content in source. `.isomorph/local/` is ignored and never committed; `.isomorph/integrations.json` and `.isomorph/kit.lock.json` are committed.",
259
- "- Schema changes are SQL files in `migrations/`, applied by `isomorph dev` and `isomorph check`. Every table: ENABLE ROW LEVEL SECURITY + a policy; GRANT every verb a policy allows to `harbour_app_gateway` and to no other role — `isomorph check` runs the pipeline's database gate and names any table/policy/grant that breaks this, with the fix.",
260
- "- `.isomorph/checks/` holds the app's retained journeys: one per capability the app's own code uses (`isomorph.data.*`, `isomorph.files.*`, actions, realtime, telemetry), plus a cross-user denial per owner-scoped table. `isomorph check` generates them from `migrations/` and the app's own source and deletes the ones the app no longer needs, so the way to keep them right is to run it in the same edit that changes the app — not to write or remove these files by hand. The pairing is two-way and the `flow` gate refuses the deploy in both directions. Start using a capability and it needs its own retained check: `isomorph check` writes it, except for the ones it reports it cannot generate (`actions`, `realtime`), which you write yourself. Stop using one — a deleted section, a dropped table, a feature the app no longer has — and its retained check must be deleted in that same edit: `isomorph check` deletes the ones it generated, and one you wrote or edited is yours to delete, because `isomorph check` and the deployment pipeline replay `.isomorph/checks/` against a real App Gateway and refuse the app (`flow.check-failed: the candidate's own retained checks no longer pass`) when a check exercises something the code no longer does.",
261
- "- A generated check starts with a `// isomorph:generated` line carrying a digest of its own body; that is how `isomorph check` knows the file is still its to rewrite and remove. Edit one and it becomes yours: Isomorph keeps your version, stops updating it and never deletes it, and keeping it honest is then your job. The starter's pairing is: `notes-journey.mjs` + `notes-cross-user.mjs` with the `notes` table and the Notes section of `src/App.tsx`; `files-journey.mjs` with the \"Private files\" section, the only code that calls `isomorph.files.*`. Replace the notes table with the app's own, or remove the \"Private files\" section, and the next `isomorph check` rewrites and deletes to match — `.isomorph/checks/files-journey.mjs` goes with that section, and you delete it by hand in that same edit only if you have edited it. An inherited check for a feature the app replaced or dropped is the most common reason a first deploy is refused.",
262
- "- Commands: `isomorph dev --app-root .` (local runtime), `isomorph check --app-root .` (types, build, then the pipeline's own kit gate in the local session: declaration, migrations + database gate, write probe with cross-user denial, the generated journeys, operation coverage), `isomorph integrations catalog --app-root .` (the company's connections and approved names, before declaring one), `isomorph integrations request <connection> --reason <text> --app-root .`, `isomorph integrations status --app-root .`, `isomorph productionise --app-root .`. Company calls in `dev` use the account from `isomorph login`; the local fixture user is only the app's identity.",
263
- "- Codex reads this AGENTS.md block; Claude Code also reads `.claude/skills/isomorph-kit/SKILL.md`. The plain-English workflow (what to run when the person says \"run it\", \"check it\", \"ship it\") is the user-level `isomorph` skill installed by `isomorph agent-setup` (Claude Code: `~/.claude/skills/isomorph`, Codex: `~/.codex/skills/isomorph`); use it for every request about this app.",
261
+ "1. Read `core.md` beside the skill's `SKILL.md` before the first edit.",
262
+ "2. Before you add a key under `\"connections\"` in `.isomorph/integrations.json`, read `integrations.md` there.",
263
+ "3. Before you call `isomorph.ai.*`, read `ai.md`; before you create `jobs/`, read `jobs.md`.",
264
+ "",
265
+ "Commands: `isomorph dev --app-root .` runs the app locally; `isomorph check --app-root . --json` runs the gates the deployment pipeline runs, before every hand-off; `isomorph productionise --app-root .` deploys the private preview.",
264
266
  MANAGED_END
265
267
  ].join("\n");
266
268
  }
267
- const SKILL_FILE = `---
268
- name: isomorph-kit
269
- description: Build, run and check this Isomorph app with the Isomorph CLI (dev, check, integrations, productionise).
270
- ---
271
-
272
- Follow the "Isomorph development kit" block in CLAUDE.md / AGENTS.md. Workflow:
273
-
274
- 1. \`isomorph dev --app-root .\` starts Postgres, storage and one Isomorph gateway (session identities, fixtures, realtime) plus Vite behind one loopback origin printed in the banner.
275
- 2. Edit \`src/\` and \`migrations/\`. Use the SDK only. \`.isomorph/integrations.json\` starts with no connections and gains one only when the app really calls a company system: declare it with only the operations the app calls, then request access (step 5). A declared connection the app does not call blocks every deploy until IT grants it; an undeclared one cannot be requested, so it never gets a grant. README.md has the worked Slack and warehouse examples — the file itself is strict JSON and cannot hold comments.
276
- 3. \`.isomorph/checks/\` is generated, not written by hand: \`isomorph check\` reads \`migrations/\` and \`src/\` and writes one retained journey per capability the app's own code uses (plus a cross-user denial per owner-scoped table), deleting the ones the app no longer needs — so run it in the same edit that changes the app instead of adding or deleting these files yourself. It reports anything it cannot generate (\`actions\`, \`realtime\`, a table with no migration) for you to write. Edit a generated check and it becomes yours: Isomorph keeps it, stops managing it and never removes it, so deleting it when the feature goes is then your job. \`isomorph check\` and the deployment pipeline replay these checks against a real App Gateway and refuse the app (\`flow.check-failed\`) in both directions — a capability no check exercises, and a check that exercises something the code no longer does.
277
- 4. \`isomorph check --app-root .\` before every hand-off; read \`.isomorph/local/check-report.json\`. Real integrations are reported as not tested unless \`--integrations\` is passed (read operations only); a retained check that calls \`isomorph.integrations.execute\` is answered by the gate's fixture (the contract's shape for what the app declared, nothing sent or read) and the passed check says so. A governed AI call (\`isomorph.ai.chat\`) is exercised for real through the development route while \`isomorph dev\` is up; the pipeline answers it with a canned completion and reports it as not tested.
278
- 5. \`isomorph integrations request <connection> --reason "<why>" --app-root .\` asks IT for access now — one request per connection, approved once for every environment; \`isomorph dev\` (signed in) and \`isomorph productionise\` file it for you. Pending is not ready.
279
- 6. \`isomorph productionise --app-root .\` saves and deploys the preview; \`isomorph promote\` after the person has tested it.
280
- `;
281
269
  /**
282
270
  * Kit infrastructure: structural files every kit app needs, on every path —
283
271
  * `init` in an empty directory, `init` over an existing Vite + React app, and
@@ -289,11 +277,11 @@ function kitFiles() {
289
277
  // No connection. The starter calls no company system, and a connection the
290
278
  // app does not call is a deploy that never happens: `isomorph productionise`
291
279
  // files the access request for every declared connection and refuses to
292
- // start until IT has approved it. One is added when the app really calls it — the two steps and the
293
- // worked Slack and warehouse examples are in README.md, in the "Isomorph
294
- // development kit" block of AGENTS.md / CLAUDE.md and in src/App.tsx. They
295
- // are not in this file: it is read with JSON.parse here and again by the
296
- // pipeline's source intake, so it cannot carry comments.
280
+ // start until IT has approved it. One is added when the app really calls it —
281
+ // the steps and the worked Slack and warehouse examples are in integrations.md
282
+ // beside the `isomorph` skill (guide.ts). They are not in this file: it is read
283
+ // with JSON.parse here and again by the pipeline's source intake, so it cannot
284
+ // carry comments.
297
285
  ".isomorph/integrations.json": `${JSON.stringify(emptyDeclaration(), null, 2)}\n`
298
286
  };
299
287
  }
@@ -335,76 +323,22 @@ function starterFiles(bundle) {
335
323
  "README.md": STARTER_README
336
324
  };
337
325
  }
338
- const STARTER_README = `# Isomorph app
339
-
340
- Created by \`isomorph init\`. Run \`isomorph dev --app-root .\` and open the printed origin. See CLAUDE.md / AGENTS.md for the kit rules.
341
-
342
- \`.isomorph/checks/\` holds one journey per capability the app uses (\`notes-journey.mjs\` for data, \`files-journey.mjs\` for files) and \`notes-cross-user.mjs\`, which proves a second signed-in person cannot read, update or delete another person's note — the same denial the deployment pipeline's write probe asserts.
343
-
344
- The directory is empty until the first \`isomorph check\`, which writes those three: it replays \`migrations/\` into a disposable database, reads this app's own tables back out of it, and generates a journey for each table and capability \`src/App.tsx\` uses. Every later \`isomorph check\` keeps them in step — adding a journey when the app starts using a table or capability, removing one when the app stops. Each begins with a \`// isomorph:generated\` line carrying a digest of its own body — edit a check and Isomorph keeps your version, stops updating it and never removes it, and it is yours to maintain from then on.
345
-
346
- The checks and the code are one pair, and \`isomorph check\` and the deployment pipeline's \`flow\` gate enforce the pair in both directions against a real App Gateway. Both directions refuse the deploy:
326
+ /** The starter's README, for the people who open the folder: what it is, the three commands, and where the rules are. Rule text lives in the `isomorph` skill only. */
327
+ export const STARTER_README = `# Isomorph app
347
328
 
348
- - **A capability with no check.** They refuse an app whose checks never exercise an operation its own code performs, so a new feature needs its own retained check.
349
- - **A check with no capability.** They refuse an app whose retained checks no longer pass against its own code, so a feature you delete or replace means deleting or rewriting its check in the same edit. Running \`isomorph check\` is that edit for a generated check: replace the \`notes\` table with your own and it rewrites both notes checks for the new table; remove the "Private files" section — the only code here that calls \`isomorph.files.*\` — and it deletes \`.isomorph/checks/files-journey.mjs\` for you. A check you have edited is not Isomorph's to remove, so \`rm .isomorph/checks/files-journey.mjs\` right then yourself; left behind, it exercises a capability the app no longer has and the deploy is refused with \`flow.check-failed: files-journey.mjs: exit status 1\`.
329
+ Created by \`isomorph init\`: a Vite + React app that runs on Isomorph. Sign-in, data, files and company systems come from the platform through \`@isomorph.ai/app-sdk\`, so the app has no backend of its own.
350
330
 
351
- ## Adding AI ("Summarise my notes")
331
+ ## Three commands
352
332
 
353
- The starter calls no AI. When the person asks for it, add one call — \`isomorph.ai.chat(...)\` on the client exported by \`src/isomorph.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.
333
+ - \`isomorph dev --app-root .\` — starts the app on this machine with a local database, file store and gateway; open the printed link.
334
+ - \`isomorph check --app-root . --json\` — the same gates the deployment pipeline runs (types, build, schema, retained checks); read \`.isomorph/local/check-report.json\` afterwards.
335
+ - \`isomorph productionise --app-root .\` — deploys a private preview for the people you name; \`isomorph promote\` makes it live for everyone once they have tried it.
354
336
 
355
- \`\`\`tsx
356
- import { isomorph, errorCode } from "./isomorph.client";
337
+ ## Where the rules are
357
338
 
358
- const summarise = async () => {
359
- try {
360
- const reply = await isomorph.ai.chat({ messages: [{ role: "user", content: \`Summarise these notes in three lines:\\n\${notes.map(n => n.title).join("\\n")}\` }], maxTokens: 200 });
361
- setSummary(reply.content);
362
- } catch (error) {
363
- 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.");
364
- }
365
- };
366
- // <button type="button" onClick={() => void summarise()}>Summarise my notes</button>
367
- \`\`\`
368
-
369
- That one call is the declaration: \`isomorph check\` writes \`.isomorph/checks/ai-journey.mjs\` for it and the deployment registers the \`ai\` capability. Locally the journey makes a real governed call through the Isomorph development route with your \`isomorph login\` (\`AI_NOT_ENABLED\` means IT has not enabled a provider yet); in the pipeline it is answered by a canned completion and reported as not tested. Remove the call and the next \`isomorph check\` deletes the journey.
370
-
371
- ## Adding a company system (Slack, Gmail, a warehouse view)
372
-
373
- \`.isomorph/integrations.json\` ships with no connections:
374
-
375
- \`\`\`json
376
- { "schema": "isomorph.app-integrations/2.0", "connections": {} }
377
- \`\`\`
378
-
379
- That is deliberate. Every declared connection blocks the deploy until IT grants it — \`isomorph productionise\` refuses to start while one is missing — so a connection the app does not call is a deploy that never happens. The file is strict JSON and cannot hold comments, which is why this note lives here.
380
-
381
- Add one in two steps, when the app really calls it:
382
-
383
- 1. Declare the connection and only the operations the app calls, under \`connections\`. Slack:
384
-
385
- \`\`\`json
386
- "company-slack": { "kind": "saas", "presentation": { "displayName": "Lunch Vote", "iconEmoji": ":sandwich:" }, "operations": { "slack.channel.history": { "identity": "user", "resources": ["team-updates"] }, "slack.message.post": { "identity": "app", "resources": ["team-updates"] } } }
387
- \`\`\`
388
-
389
- \`slack.message.post\` is posted either as the app (\`"identity": "app"\`: the company's Slack bot, Isomorph AI, under the \`presentation\` name IT approves — leave \`presentation\` out to post as Isomorph AI itself; every app-mode post ends with "Posted by <app> on Isomorph") or as the person (\`"identity": "user"\`: their own Slack account, after their consent — an older consent answers \`USER_RECONNECT_REQUIRED\` "reconnect Slack to allow posting as you", so offer Connect again). Never pretend one is the other. The declaration is the mode the app requests access for; a post names the approved mode it runs under with \`mode: "app"\` or \`mode: "user"\` on the call — optional while the app is approved for one mode, required once IT approved both (\`MODE_REQUIRED\`), and a mode IT has not approved is refused with \`MODE_NOT_GRANTED\`, never swapped for the other. Either way the bot must be in the channel: someone runs \`/invite @Isomorph AI\` there once, or the post is refused with \`RESOURCE_NOT_APPROVED\`.
390
-
391
- A warehouse view:
392
-
393
- \`\`\`json
394
- "sales-warehouse": { "kind": "database", "operations": { "warehouse.view.read": { "identity": "app", "resources": { "weekly_sales": { "columns": ["week", "total"] } } } } }
395
- \`\`\`
396
-
397
- 2. Ask IT for access: one request per connection, and IT approves it once for every environment (development, preview and production together). \`isomorph dev\` (signed in) and \`isomorph productionise\` file it for you; \`isomorph integrations request <connection> --reason "<why>" --app-root .\` files it now. \`isomorph integrations status --app-root .\` says where each connection stands in every environment; PENDING is not ready, and \`isomorph productionise\` will not deploy until IT has approved every declared connection.
398
-
399
- Then call it from the app on the client exported by \`src/isomorph.client.ts\` — always this exact shape, never a wrapper function, because the kit gate derives what the app uses from it:
400
-
401
- \`\`\`ts
402
- const report = await isomorph.integrations.execute("sales-warehouse", {
403
- operation: "warehouse.view.read", resource: "weekly_sales", input: { columns: ["week", "total"], limit: 100 }
404
- });
405
- \`\`\`
339
+ The kit's rules are in the \`isomorph\` skill that \`isomorph agent-setup\` installs for Claude Code (\`~/.claude/skills/isomorph/\`) and Codex (\`~/.codex/skills/isomorph/\`): \`core.md\` (identity, data, files, realtime, retained checks, commands, error codes), \`integrations.md\` (Slack, Gmail, warehouse), \`ai.md\` and \`jobs.md\`. The managed block in CLAUDE.md / AGENTS.md says when to read each, and \`init --upgrade\` keeps that block current.
406
340
 
407
- 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 \`isomorph 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"\`): \`isomorph.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 \`.isomorph/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 \`isomorph.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; \`isomorph check --integrations\` is what exercises real access.
341
+ \`.isomorph/checks/\` is generated by \`isomorph check\` from \`migrations/\` and \`src/\` — do not write it by hand. \`.isomorph/integrations.json\` declares the company systems the app calls: none, to start.
408
342
  `;
409
343
  const VITE_CONFIG = `import { defineConfig } from "vite";
410
344
  import react from "@vitejs/plugin-react";
@@ -429,24 +363,9 @@ const ISOMORPH_CLIENT = `import { createClient } from "@isomorph.ai/app-sdk";
429
363
  // the same calls work locally (isomorph dev) and in preview/production.
430
364
  export const isomorph = createClient();
431
365
 
432
- // One call shape, everywhere: the SDK's own namespaces on this client —
433
- // isomorph.integrations.execute(...), isomorph.ai.chat(...), isomorph.data.from(...),
434
- // isomorph.files.* — and no wrapper functions around them. The kit gate derives
435
- // what the app uses from exactly these calls; a wrapper would hide them.
436
- //
437
- // Gmail sends plain text as the signed-in person, after IT approval and personal consent.
438
- // Inside an authorized Send handler (use the catalog's actual connection and mailbox):
439
- // await isomorph.integrations.execute("company-gmail", {
440
- // operation: "gmail.message.send", resource: "inbox",
441
- // input: { to: ["recipient@example.com"], subject: "Update", text: "Your message" },
442
- // idempotencyKey: crypto.randomUUID()
443
- // });
444
- //
445
- // Governed AI through the platform's LLM gateway: the app never holds a provider key and
446
- // IT sees every call. The starter calls no AI; when the person asks for it, add the one
447
- // call — isomorph.ai.chat({ messages, maxTokens }) — behind an explicit control they press,
448
- // never on load. README.md has the "Summarise my notes" example. An IsomorphError whose
449
- // errorCode() is AI_NOT_ENABLED means IT has not enabled a provider yet: keep the app working.
366
+ // Call the SDK's namespaces on this client directly (isomorph.data.from, isomorph.files.*,
367
+ // isomorph.integrations.execute, isomorph.ai.chat). Rules: core.md in the isomorph skill;
368
+ // integrations.md and ai.md for company systems and AI.
450
369
 
451
370
  export function errorCode(error: unknown): string {
452
371
  const details = (error as { details?: { code?: string } } | undefined)?.details;
@@ -491,32 +410,9 @@ export function App() {
491
410
  const remove = async (note: Note) => { await isomorph.data.from("notes").delete().eq("id", note.id); await loadNotes(); };
492
411
  const upload = async (file: File) => { await isomorph.files.upload(\`private/\${file.name}\`, file); await loadFiles(); }; // .isomorph/checks/files-journey.mjs
493
412
 
494
- // Company systems are not part of the starter: .isomorph/integrations.json declares
495
- // nothing, so nothing about this app waits on IT. Add one only when the app really
496
- // calls it, in two steps (README.md has the same two steps in full).
497
- // 1. Declare the connection and only the operations this app calls, under
498
- // "connections" in .isomorph/integrations.json — every declared connection blocks
499
- // the deploy until IT grants it:
500
- // "sales-warehouse": { "kind": "database", "operations": { "warehouse.view.read": { "identity": "app", "resources": { "weekly_sales": { "columns": ["week", "total"] } } } } }
501
- // "company-slack": { "kind": "saas", "presentation": { "displayName": "Lunch Vote" }, "operations": { "slack.channel.history": { "identity": "user", "resources": ["team-updates"] }, "slack.message.post": { "identity": "app", "resources": ["team-updates"] } } }
502
- // (slack.message.post "identity": "app" posts as the company's Slack bot, Isomorph AI, under the
503
- // IT-approved presentation name; "identity": "user" posts as the signed-in person after their consent.
504
- // A post may name the approved mode it runs under — { ..., mode: "app" } — and must once IT approved both.)
505
- // 2. Request access — one request per connection, which IT approves once for every
506
- // environment; isomorph dev and isomorph productionise file it for you, or file it now with
507
- // isomorph integrations request sales-warehouse --reason "<why>" --app-root . — then uncomment
508
- // (the isomorph client is already imported from "./isomorph.client"):
509
- // const report = await isomorph.integrations.execute("sales-warehouse", {
510
- // operation: "warehouse.view.read", resource: "weekly_sales", input: { columns: ["week", "total"], limit: 100 }
511
- // });
512
- // 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
513
- // fresh UUID idempotencyKey per press — never from an effect, a timer or a check.
514
- //
515
- // Governed AI is not part of the starter either. When the person asks for it, add ONE
516
- // call isomorph.ai.chat on the client from "./isomorph.client" behind a control they press (README.md
517
- // "Adding AI"), never an OpenAI/Anthropic key or SDK; isomorph check then writes
518
- // .isomorph/checks/ai-journey.mjs for it, and deletes it again if the call goes:
519
- // const summary = await isomorph.ai.chat({ messages: [{ role: "user", content: \`Summarise these notes in three lines:\\n\${notes.map(n => n.title).join("\\n")}\` }], maxTokens: 200 });
413
+ // Company systems and AI are not part of the starter: .isomorph/integrations.json declares
414
+ // nothing and no isomorph.ai call exists, so nothing here waits on IT. Rules for adding
415
+ // either: integrations.md and ai.md in the isomorph skill.
520
416
 
521
417
  return (
522
418
  <main style={{ fontFamily: "system-ui", maxWidth: 720, margin: "2rem auto", padding: "0 1rem" }}>
@@ -524,10 +420,9 @@ export function App() {
524
420
  <p>Signed in as <strong>{user ? user.email : "…"}</strong> (identity from Isomorph; locally the fixture user).</p>
525
421
  {error && <p role="alert" style={{ color: "crimson" }}>{error}</p>}
526
422
 
527
- {/* Notes — paired with .isomorph/checks/notes-journey.mjs (data) and
528
- .isomorph/checks/notes-cross-user.mjs, both generated from this section and
529
- migrations/0001_notes.sql. Replace this table with the app's own and run
530
- \`isomorph check\`: it rewrites both checks for the new table in that same edit. */}
423
+ {/* Notes — paired with .isomorph/checks/notes-journey.mjs and notes-cross-user.mjs, generated
424
+ from this section and migrations/0001_notes.sql; replace the table and \`isomorph check\`
425
+ rewrites both. Rules: core.md in the isomorph skill (data). */}
531
426
  <section>
532
427
  <h2>Notes</h2>
533
428
  <form onSubmit={event => { event.preventDefault(); void addNote(); }}>
@@ -544,12 +439,10 @@ export function App() {
544
439
  </ul>
545
440
  </section>
546
441
 
547
- {/* Private files — the only code in this app that calls isomorph.files.*, and so the
548
- only reason .isomorph/checks/files-journey.mjs exists. Delete this section and the next
549
- \`isomorph check\` deletes that check with it; delete it by hand in the same edit if you
550
- have edited it, because the deploy's flow gate replays .isomorph/checks/ against a real
551
- App Gateway and refuses an app whose checks exercise a capability its code no longer
552
- has ("flow.check-failed: files-journey.mjs: exit status 1"). */}
442
+ {/* Private files — the only code here that calls isomorph.files.*, and so the only reason
443
+ .isomorph/checks/files-journey.mjs exists: delete this section and \`isomorph check\` deletes
444
+ that check too (one you have edited is yours to delete, in the same edit). Rules: core.md
445
+ in the isomorph skill (retained checks). */}
553
446
  <section>
554
447
  <h2>Private files</h2>
555
448
  <input type="file" aria-label="Upload file" onChange={event => { const file = event.target.files?.[0]; if (file) void upload(file); }} />
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.2.2-rc.1";
1
+ export const CLI_VERSION = "0.3.0-rc.1";
@@ -118,7 +118,11 @@ function safeSourcePath(path) {
118
118
  function toBytes(value) { return typeof value === "string" ? new TextEncoder().encode(value) : value; }
119
119
  function bytesToBase64(bytes) { return Buffer.from(bytes).toString("base64"); }
120
120
  function base64ToBytes(value) {
121
- if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value))
121
+ // Length-multiple-of-four plus a single character class: the same language as
122
+ // the padded-quartet grammar, checked without a quantified group. V8 backtracks
123
+ // per quartet of a grouped quantifier, so the grammar form overflowed the stack
124
+ // on any file over ~3 MB (a 42 MB SQLite file shipped inside an app package).
125
+ if (value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value))
122
126
  throw new Error("SOURCE_ARCHIVE_INVALID: file is not valid base64.");
123
127
  return new Uint8Array(Buffer.from(value, "base64"));
124
128
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isomorph.ai/cli",
3
- "version": "0.2.2-rc.1",
3
+ "version": "0.3.0-rc.1",
4
4
  "description": "Isomorph development kit CLI",
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.2.0"
40
+ "version": "0.3.0"
41
41
  }
42
42
  }
43
43
  }