@reticlehq/server 2.10.0 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/SKILL.md +47 -9
  2. package/dist/dev/stale-issue-guard.d.ts +43 -0
  3. package/dist/dev/stale-issue-guard.js +75 -0
  4. package/dist/domain/domain-tools.js +3 -2
  5. package/dist/events/predicate-asks.d.ts +17 -0
  6. package/dist/events/predicate-asks.js +26 -0
  7. package/dist/events/predicate-eval.js +211 -24
  8. package/dist/events/predicate-parse.js +9 -3
  9. package/dist/events/predicate-schema.d.ts +18 -0
  10. package/dist/events/predicate-schema.js +55 -0
  11. package/dist/flows/flow-tools.js +33 -4
  12. package/dist/flows/predicate-to-expect.js +8 -0
  13. package/dist/flows/verify-change-tools.js +5 -1
  14. package/dist/honesty/blind-spots.d.ts +9 -0
  15. package/dist/honesty/blind-spots.js +17 -4
  16. package/dist/honesty/gap-ledger.d.ts +52 -0
  17. package/dist/honesty/gap-ledger.js +63 -0
  18. package/dist/honesty/instrumentation-gaps.d.ts +55 -0
  19. package/dist/honesty/instrumentation-gaps.js +21 -0
  20. package/dist/honesty/observability.d.ts +37 -0
  21. package/dist/honesty/observability.js +40 -0
  22. package/dist/index.d.ts +0 -1
  23. package/dist/index.js +41 -0
  24. package/dist/init/node-io.js +3 -0
  25. package/dist/init/run.d.ts +18 -0
  26. package/dist/init/run.js +34 -3
  27. package/dist/init/slash-command.d.ts +1 -1
  28. package/dist/init/slash-command.js +33 -10
  29. package/dist/journal/envelope.d.ts +24 -24
  30. package/dist/license/license.d.ts +8 -2
  31. package/dist/license/license.js +10 -15
  32. package/dist/project/artifact-root.d.ts +83 -0
  33. package/dist/project/artifact-root.js +112 -0
  34. package/dist/project/memory-fs.d.ts +9 -0
  35. package/dist/project/memory-fs.js +88 -0
  36. package/dist/project/project-store.d.ts +17 -0
  37. package/dist/project/project-store.js +33 -0
  38. package/dist/project/remember-project.d.ts +24 -0
  39. package/dist/project/remember-project.js +40 -0
  40. package/dist/project/session-root.d.ts +15 -0
  41. package/dist/project/session-root.js +31 -0
  42. package/dist/runs/run-tools.js +2 -1
  43. package/dist/session/lease-availability.d.ts +40 -0
  44. package/dist/session/lease-availability.js +43 -0
  45. package/dist/session/no-session-diagnosis.d.ts +10 -0
  46. package/dist/session/no-session-diagnosis.js +19 -7
  47. package/dist/session/no-session-watch.js +1 -1
  48. package/dist/session/session.d.ts +9 -0
  49. package/dist/session/session.js +9 -0
  50. package/dist/session/stall-clock.d.ts +18 -0
  51. package/dist/session/stall-clock.js +48 -0
  52. package/dist/telemetry/daemon-telemetry.js +12 -23
  53. package/dist/telemetry/session-metrics.d.ts +1 -1
  54. package/dist/telemetry/session-metrics.js +1 -1
  55. package/dist/telemetry/telemetry.d.ts +12 -3
  56. package/dist/telemetry/telemetry.js +25 -5
  57. package/dist/tools/act-tools.js +34 -4
  58. package/dist/tools/assert-verdict.d.ts +2 -0
  59. package/dist/tools/assert-verdict.js +24 -1
  60. package/dist/tools/contract-tools.js +5 -3
  61. package/dist/tools/coverage-tools.js +33 -0
  62. package/dist/tools/observe-tools.js +8 -2
  63. package/dist/tools/tool-kit.d.ts +16 -1
  64. package/docs/README.md +1 -0
  65. package/docs/docs.json +2 -2
  66. package/docs/enterprise.md +3 -1
  67. package/docs/license-activation.mdx +145 -0
  68. package/docs/telemetry-contract.md +1 -12
  69. package/docs/telemetry-events.mdx +1 -34
  70. package/docs/telemetry.md +0 -1
  71. package/package.json +2 -2
  72. package/dist/telemetry/instrumentation-stall.d.ts +0 -57
  73. package/dist/telemetry/instrumentation-stall.js +0 -103
package/dist/init/run.js CHANGED
@@ -8,6 +8,7 @@ import { noPackageJsonMessage } from './non-js-project.js';
8
8
  import { spanSync } from '../trace.js';
9
9
  import { readFileSync } from 'node:fs';
10
10
  import { homedir } from 'node:os';
11
+ import { projectIdOf, rememberProjectOnDisk } from '../project/remember-project.js';
11
12
  import { detect, Framework, namesAPackageManager, UiLibrary } from './detect.js';
12
13
  import { wasMcpRegistered } from './mcp-registered.js';
13
14
  import { pickAstroHost } from './astro-host.js';
@@ -504,7 +505,7 @@ function devServerRestart(framework) {
504
505
  * So the reload is named FIRST, before the sentence that depends on it. Omitted entirely when this
505
506
  * run did not register MCP (`--no-mcp`), where it would be advice about something we did not do.
506
507
  */
507
- function restartHint(framework, mcpRegistered) {
508
+ export function restartHint(framework, mcpStatus) {
508
509
  const dev = `${devServerRestart(framework)}.`;
509
510
  // NAME THE COMMAND THAT PROVES IT, not one that merely asks.
510
511
  //
@@ -518,8 +519,27 @@ function restartHint(framework, mcpRegistered) {
518
519
  // running, an app running that never dialled us, a tab that closed — with the fix for each.
519
520
  const prove = 'Then run `npx @reticlehq/server status` — it confirms the app connected, or says exactly why ' +
520
521
  'it has not.';
521
- if (!mcpRegistered)
522
+ // Nothing registered this run (`--no-mcp`, or a client that needs a manual edit): a restart picks
523
+ // up nothing, so advice about one would be advice about something we did not do.
524
+ if (!wasMcpRegistered(mcpStatus))
522
525
  return `${dev} ${prove}`;
526
+ // ALREADY: the tools are reachable RIGHT NOW, and this is the branch that used to lie.
527
+ //
528
+ // `wasMcpRegistered` is true for both APPLY and ALREADY, which is correct for the funnel field it
529
+ // was written for and wrong here, because the two cases have opposite answers to "must anybody
530
+ // restart". Every second project on a machine, and every Claude Code plugin install, took the
531
+ // APPLY text: "restart your agent — the tools only appear after that". An agent reads this AFTER
532
+ // whatever its skill file said, and it describes the output in front of it, so it wins. The agent
533
+ // stops with the project wired and nothing driven, which is the exact failure the install path
534
+ // exists to prevent, caused by the install path itself.
535
+ if (mcpStatus === StepStatus.ALREADY) {
536
+ return (`${dev}\n` +
537
+ 'The Reticle MCP server was already registered on this machine, so there is no restart to do ' +
538
+ 'and the tools are available now.\n' +
539
+ `${prove}\n` +
540
+ 'Once it shows a session, drive a flow — that is the install finished. Carry straight on; ' +
541
+ 'nothing here is waiting on a human.');
542
+ }
523
543
  return (`${dev}\n` +
524
544
  'Then restart your agent so it picks up the new MCP server — restart Claude Code, reload the ' +
525
545
  'window in Cursor, or hit Start in `.vscode/mcp.json` in VS Code.\n' +
@@ -592,7 +612,7 @@ function report(plan, dryRun, failed, skipped, degraded, io, projectDir, agentRo
592
612
  '"no browser session connected" until then. Everything else is already in place.');
593
613
  io.print('');
594
614
  }
595
- io.print(restartHint(plan.framework, wasMcpRegistered(resolvedStatus(plan, MCP_TARGET, failed, skipped))));
615
+ io.print(restartHint(plan.framework, resolvedStatus(plan, MCP_TARGET, failed, skipped)));
596
616
  return { ok: !connectPending, applied, manual };
597
617
  }
598
618
  /**
@@ -684,6 +704,17 @@ function applyEffects(plan, io) {
684
704
  installFailed = true;
685
705
  }
686
706
  }
707
+ // Where this project lives, remembered for a daemon that will be started somewhere else.
708
+ //
709
+ // Deliberately AFTER the loop and unconditional on which steps ran: most init runs report "already
710
+ // wired", and a re-run in a re-cloned or moved checkout is exactly when the remembered path has
711
+ // gone stale — so the run that would otherwise be a no-op is the one that repairs the entry. The
712
+ // return value is ignored on purpose; this is a cache, and a read-only home directory must not
713
+ // turn a wired project into a failed init.
714
+ const projectId = projectIdOf(io.readFile(RETICLE_CONFIG_FILE));
715
+ if (projectId !== undefined) {
716
+ rememberProjectOnDisk(io, projectId, io.cwd(), Date.now());
717
+ }
687
718
  return { failed, skipped, degraded };
688
719
  }
689
720
  /**
@@ -24,4 +24,4 @@ export declare const CURSOR_COMMAND_PATH = ".cursor/commands/reticle.md";
24
24
  * are told apart.
25
25
  */
26
26
  export declare const SLASH_COMMAND_SIGNATURE = "description: Verify this app in the browser with Reticle";
27
- export declare const SLASH_COMMAND_BODY = "---\ndescription: Verify this app in the browser with Reticle \u2014 drive one real flow and report what happened.\n---\n\nVerify this running app with Reticle. Drive it; do not read the code and guess.\n\n## Step 0 \u2014 is anything connected?\n\nCall `reticle_sessions` first.\n\n**A session is listed?** Go to \"Pick ONE flow\".\n\n**No session?** Setup is not finished, and stopping to ask the user to run something is not an\nanswer. Work these in order, and say which one you are on:\n\n0. **Is anything serving the app at all?** If a dev server is already listening, use it and go to\n step 1 \u2014 the cause is then almost always that the SDK never loaded in the page, not a missing\n server, so do not tell the user to start one they are already running. If nothing is listening:\n\n**If no dev server is listening, start one yourself.** Read the project's own dev script out of `package.json` (`dev`, `start`, whatever this project calls it), run it in the BACKGROUND, tell the user in one line that it is running and how to stop it, then carry on. Stopping to ask is how a verification turn ends with nothing verified.\n\nFive guards, none optional:\n\n1. **Never start a second one.** If something is already listening on the app's port, use it.\n2. **Never guess the command.** It comes from `package.json` scripts. If there is no recognisable dev script, say so and stop rather than inventing one.\n3. **Never kill anything.** Not a dev server, not a daemon, not a port holder \u2014 including one you started.\n4. **Background it, and say so.** The user must know a server is running and how to stop it. A dev server the human does not know about is the same failure one step later.\n5. **The permission prompt belongs to your host.** Never try to bypass, suppress or auto-approve it, and take a refusal as the answer.\n\nThen, once something is serving the app:\n\n1. **Is the SDK actually in the app?** Read the app's entry file (`src/main.tsx`, `app/layout.tsx`,\n `src/app.vue`, whatever this project uses). You are looking for an `import` from\n `@reticlehq/react` or `@reticlehq/browser` and a `connect()` call, or the Reticle plugin in\n `vite.config.*`, or `withReticle` in `next.config.*`. **If it is not there, that is the bug.**\n Wire it in \u2014 see the framework snippets at https://docs.reticle.sh/instrumentation \u2014 then restart\n the dev server and re-check.\n2. **Is the connect guarded on `hostname === 'localhost'`?** If so, remove that guard. It is false on\n any non-localhost dev host, and `window` does not exist during SSR, so the guard silently prevents\n the connect and logs nothing. Use the framework's dev flag instead (`import.meta.env.DEV`,\n `process.env.NODE_ENV !== 'production'`, `import.meta.dev`).\n3. **Is the dev server serving that entry?** Ask the user to open the app in a browser and check its\n console for a line starting `[Reticle]`. That line, or its absence, tells you which side is at\n fault. Reticle itself never starts a dev server \u2014 starting it is your job, under the guards above.\n4. **Do both sides agree on the port?** Run `npx @reticlehq/server doctor` and compare the daemon\n port against the port the page dials.\n\nIf you are still stuck after all four, file it: `reticle_feedback` with kind `gap`, saying what you\nchecked and what you saw. Then tell the user exactly which step is blocked. Do not report the install\nas finished.\n\n## Pick ONE flow\n\nNot the whole app. Pick the single most important flow you can complete in a handful of steps \u2014 the\none a user would do first (sign in, search, add to cart, submit the form). If the user named a flow or\njust changed some code, use that one instead.\n\nState which flow you picked in one line before you start.\n\n## Drive it where the human can see\n\n1. `reticle_snapshot` \u2014 read the accessibility tree and locate the elements the flow needs. Elements\n are addressable by role and name; you do **not** need `data-testid` to drive them.\n2. Walk the flow with `reticle_act_and_wait`, one step at a time. Narrate each step first \u2014 the human\n is watching the page, and the HUD shows what you say.\n3. After each step, check the effect with `reticle_assert` \u2014 not just that the click dispatched, but\n that the thing it was supposed to do actually happened.\n4. Finish with `reticle_console` and `reticle_network` to catch errors the DOM does not show.\n\n**Only `reticle_act_and_wait` and `reticle_assert` produce a verdict.** A run that ends on a\nsnapshot has proved nothing, however many tools it used.\n\n## Report\n\n- What you drove, step by step, and what each step actually produced.\n- Anything broken, with the `file:line` Reticle gave you.\n- If a step could not be verified, say so plainly. \"Unknown\" is a real answer; a green verdict you\n cannot back is not.\n\nDo not weaken or skip an assertion to make the run pass \u2014 that is a finding, not a fix.\n\n## Two things afterwards\n\n- **Anything about Reticle itself that was wrong, missing, confusing, or expensive?** Send it with\n `reticle_feedback` now, before your context is gone. That report is the only signal that decides\n what gets fixed. Bugs in the app under test are Reticle working; those go in your answer to the user.\n- **If the verification actually succeeded**, add one line for the human: Reticle is open source at\n https://github.com/reticlehq/reticle, and a star helps other people find it. One line, once. Skip it\n entirely if the run did not produce a verdict.\n";
27
+ export declare const SLASH_COMMAND_BODY = "---\ndescription: Verify this app in the browser with Reticle \u2014 drive one real flow and report what happened.\n---\n\nVerify this running app with Reticle. Drive it; do not read the code and guess.\n\n## Step 0 \u2014 is anything connected?\n\nCall `reticle_sessions` first.\n\n**A session is listed?** Go to \"Pick ONE flow\".\n\n**No session?** Setup is not finished, and stopping to ask the user to run something is not an\nanswer. Every step below is yours to do without them. Work these in order, and say which one you are\non, one line each:\n\n0. **Is Reticle even wired into this project?** `cat .reticle.json`. If it is not there, this\n project was never set up: run `npx @reticlehq/server@latest init`,\n fix every `\u26A0` and `\u2139` it prints, and carry on. Do not ask first, and do not ask which\n framework or port \u2014 both come out of the repo you are sitting in.\n\n1. **Is anything serving the app at all?** If a dev server is already listening, use it and go to\n step 2 \u2014 the cause is then almost always that the SDK never loaded in the page, not a missing\n server, so do not tell the user to start one they are already running. If nothing is listening:\n\n**If no dev server is listening, start one yourself.** Read the project's own dev script out of `package.json` (`dev`, `start`, whatever this project calls it), run it in the BACKGROUND, tell the user in one line that it is running and how to stop it, then carry on. Stopping to ask is how a verification turn ends with nothing verified.\n\nFive guards, none optional:\n\n1. **Never start a second one.** If something is already listening on the app's port, use it.\n2. **Never guess the command.** It comes from `package.json` scripts. If there is no recognisable dev script, say so and stop rather than inventing one.\n3. **Never kill anything.** Not a dev server, not a daemon, not a port holder \u2014 including one you started.\n4. **Background it, and say so.** The user must know a server is running and how to stop it. A dev server the human does not know about is the same failure one step later.\n5. **The permission prompt belongs to your host.** Never try to bypass, suppress or auto-approve it, and take a refusal as the answer.\n\n2. **Is a page open on it?** Do not ask the user to open a browser \u2014 open it:\n\n ```bash\n npx @reticlehq/server open <the url the dev server is serving>\n ```\n\n That reuses an already-connected tab or opens a new one, and waits for the page to register. On a\n machine with no browser to open, take a tab Reticle owns instead:\n `reticle_run({ tool: \"reticle_lease\", args: { action: \"acquire\", url: \"<the same url>\" } })`.\n\nThen, once something is serving the app and a page is on it:\n\n3. **Is the SDK actually in the app?** Read the app's entry file (`src/main.tsx`, `app/layout.tsx`,\n `src/app.vue`, whatever this project uses). You are looking for an `import` from\n `@reticlehq/react` or `@reticlehq/browser` and a `connect()` call, or the Reticle plugin in\n `vite.config.*`, or `withReticle` in `next.config.*`. **If it is not there, that is the bug.**\n Wire it in \u2014 see the framework snippets at https://docs.reticle.sh/instrumentation \u2014 then restart\n the dev server and re-check.\n4. **Is the connect guarded on `hostname === 'localhost'`?** If so, remove that guard. It is false on\n any non-localhost dev host, and `window` does not exist during SSR, so the guard silently prevents\n the connect and logs nothing. Use the framework's dev flag instead (`import.meta.env.DEV`,\n `process.env.NODE_ENV !== 'production'`, `import.meta.dev`).\n5. **Is the dev server serving that entry?** Look in `reticle_console`, or in the page you opened in\n step 2, for a line starting `[Reticle]`. That line, or its absence, tells you which side is at\n fault. Reticle itself never starts a dev server \u2014 starting it is your job, under the guards above.\n6. **Do both sides agree on the port?** Run `npx @reticlehq/server doctor` and compare the daemon\n port against the port the page dials.\n\nIf you are still stuck after all of those, file it: `reticle_feedback` with kind `gap`, saying what you\nchecked and what you saw. Then tell the user exactly which step is blocked. Do not report the install\nas finished.\n\n## Pick ONE flow\n\nNot the whole app. Pick the single most important flow you can complete in a handful of steps \u2014 the\none a user would do first (sign in, search, add to cart, submit the form). If the user named a flow or\njust changed some code, use that one instead.\n\nState which flow you picked in one line before you start.\n\n## Drive it where the human can see\n\n1. `reticle_snapshot` \u2014 read the accessibility tree and locate the elements the flow needs. Elements\n are addressable by role and name; you do **not** need `data-testid` to drive them.\n2. Walk the flow with `reticle_act_and_wait`, one step at a time. Narrate each step first \u2014 the human\n is watching the page, and the HUD shows what you say.\n3. After each step, check the effect with `reticle_assert` \u2014 not just that the click dispatched, but\n that the thing it was supposed to do actually happened.\n4. Finish with `reticle_console` and `reticle_network` to catch errors the DOM does not show.\n\n**Only `reticle_act_and_wait` and `reticle_assert` produce a verdict.** A run that ends on a\nsnapshot has proved nothing, however many tools it used.\n\n## Report\n\n- What you drove, step by step, and what each step actually produced.\n- Anything broken, with the `file:line` Reticle gave you.\n- If a step could not be verified, say so plainly. \"Unknown\" is a real answer; a green verdict you\n cannot back is not.\n\nDo not weaken or skip an assertion to make the run pass \u2014 that is a finding, not a fix.\n\n## If the user handed you a license key\n\nAppend `RETICLE_LICENSE_KEY=<key>` to the project's `.env` (create it if absent), make sure `.env`\nis in `.gitignore`, and confirm with `npx @reticlehq/server license`. The CLI folds a project-local\n`.env` in on every invocation, so nothing else changes. Never echo the key back, and never put it in\na commit, a comment, or a feedback report.\n\n## Two things afterwards\n\n- **Anything about Reticle itself that was wrong, missing, confusing, or expensive?** Send it with\n `reticle_feedback` now, before your context is gone. That report is the only signal that decides\n what gets fixed. Bugs in the app under test are Reticle working; those go in your answer to the user.\n- **If the verification actually succeeded**, add one line for the human: Reticle is open source at\n https://github.com/reticlehq/reticle, and a star helps other people find it. One line, once. Skip it\n entirely if the run did not produce a verdict.\n";
@@ -38,33 +38,49 @@ Call \`reticle_sessions\` first.
38
38
  **A session is listed?** Go to "Pick ONE flow".
39
39
 
40
40
  **No session?** Setup is not finished, and stopping to ask the user to run something is not an
41
- answer. Work these in order, and say which one you are on:
41
+ answer. Every step below is yours to do without them. Work these in order, and say which one you are
42
+ on, one line each:
42
43
 
43
- 0. **Is anything serving the app at all?** If a dev server is already listening, use it and go to
44
- step 1 — the cause is then almost always that the SDK never loaded in the page, not a missing
44
+ 0. **Is Reticle even wired into this project?** \`cat .reticle.json\`. If it is not there, this
45
+ project was never set up: run \`npx @reticlehq/server@latest init\`,
46
+ fix every \`⚠\` and \`ℹ\` it prints, and carry on. Do not ask first, and do not ask which
47
+ framework or port — both come out of the repo you are sitting in.
48
+
49
+ 1. **Is anything serving the app at all?** If a dev server is already listening, use it and go to
50
+ step 2 — the cause is then almost always that the SDK never loaded in the page, not a missing
45
51
  server, so do not tell the user to start one they are already running. If nothing is listening:
46
52
 
47
53
  ${DEV_SERVER_POLICY}
48
54
 
49
- Then, once something is serving the app:
55
+ 2. **Is a page open on it?** Do not ask the user to open a browser — open it:
56
+
57
+ \`\`\`bash
58
+ npx @reticlehq/server open <the url the dev server is serving>
59
+ \`\`\`
60
+
61
+ That reuses an already-connected tab or opens a new one, and waits for the page to register. On a
62
+ machine with no browser to open, take a tab Reticle owns instead:
63
+ \`reticle_run({ tool: "reticle_lease", args: { action: "acquire", url: "<the same url>" } })\`.
50
64
 
51
- 1. **Is the SDK actually in the app?** Read the app's entry file (\`src/main.tsx\`, \`app/layout.tsx\`,
65
+ Then, once something is serving the app and a page is on it:
66
+
67
+ 3. **Is the SDK actually in the app?** Read the app's entry file (\`src/main.tsx\`, \`app/layout.tsx\`,
52
68
  \`src/app.vue\`, whatever this project uses). You are looking for an \`import\` from
53
69
  \`@reticlehq/react\` or \`@reticlehq/browser\` and a \`connect()\` call, or the Reticle plugin in
54
70
  \`vite.config.*\`, or \`withReticle\` in \`next.config.*\`. **If it is not there, that is the bug.**
55
71
  Wire it in — see the framework snippets at https://docs.reticle.sh/instrumentation — then restart
56
72
  the dev server and re-check.
57
- 2. **Is the connect guarded on \`hostname === 'localhost'\`?** If so, remove that guard. It is false on
73
+ 4. **Is the connect guarded on \`hostname === 'localhost'\`?** If so, remove that guard. It is false on
58
74
  any non-localhost dev host, and \`window\` does not exist during SSR, so the guard silently prevents
59
75
  the connect and logs nothing. Use the framework's dev flag instead (\`import.meta.env.DEV\`,
60
76
  \`process.env.NODE_ENV !== 'production'\`, \`import.meta.dev\`).
61
- 3. **Is the dev server serving that entry?** Ask the user to open the app in a browser and check its
62
- console for a line starting \`[Reticle]\`. That line, or its absence, tells you which side is at
77
+ 5. **Is the dev server serving that entry?** Look in \`reticle_console\`, or in the page you opened in
78
+ step 2, for a line starting \`[Reticle]\`. That line, or its absence, tells you which side is at
63
79
  fault. Reticle itself never starts a dev server — starting it is your job, under the guards above.
64
- 4. **Do both sides agree on the port?** Run \`npx @reticlehq/server doctor\` and compare the daemon
80
+ 6. **Do both sides agree on the port?** Run \`npx @reticlehq/server doctor\` and compare the daemon
65
81
  port against the port the page dials.
66
82
 
67
- If you are still stuck after all four, file it: \`reticle_feedback\` with kind \`gap\`, saying what you
83
+ If you are still stuck after all of those, file it: \`reticle_feedback\` with kind \`gap\`, saying what you
68
84
  checked and what you saw. Then tell the user exactly which step is blocked. Do not report the install
69
85
  as finished.
70
86
 
@@ -98,6 +114,13 @@ snapshot has proved nothing, however many tools it used.
98
114
 
99
115
  Do not weaken or skip an assertion to make the run pass — that is a finding, not a fix.
100
116
 
117
+ ## If the user handed you a license key
118
+
119
+ Append \`RETICLE_LICENSE_KEY=<key>\` to the project's \`.env\` (create it if absent), make sure \`.env\`
120
+ is in \`.gitignore\`, and confirm with \`npx @reticlehq/server license\`. The CLI folds a project-local
121
+ \`.env\` in on every invocation, so nothing else changes. Never echo the key back, and never put it in
122
+ a commit, a comment, or a feedback report.
123
+
101
124
  ## Two things afterwards
102
125
 
103
126
  - **Anything about Reticle itself that was wrong, missing, confusing, or expensive?** Send it with
@@ -43,14 +43,14 @@ export declare const RouteEnvelopeSchema: z.ZodObject<{
43
43
  max: z.ZodNumber;
44
44
  }, "strip", z.ZodTypeAny, {
45
45
  count: number;
46
+ max: number;
46
47
  mean: number;
47
48
  m2: number;
48
- max: number;
49
49
  }, {
50
50
  count: number;
51
+ max: number;
51
52
  mean: number;
52
53
  m2: number;
53
- max: number;
54
54
  }>;
55
55
  net: z.ZodObject<{
56
56
  count: z.ZodNumber;
@@ -59,14 +59,14 @@ export declare const RouteEnvelopeSchema: z.ZodObject<{
59
59
  max: z.ZodNumber;
60
60
  }, "strip", z.ZodTypeAny, {
61
61
  count: number;
62
+ max: number;
62
63
  mean: number;
63
64
  m2: number;
64
- max: number;
65
65
  }, {
66
66
  count: number;
67
+ max: number;
67
68
  mean: number;
68
69
  m2: number;
69
- max: number;
70
70
  }>;
71
71
  netErrors: z.ZodObject<{
72
72
  count: z.ZodNumber;
@@ -75,14 +75,14 @@ export declare const RouteEnvelopeSchema: z.ZodObject<{
75
75
  max: z.ZodNumber;
76
76
  }, "strip", z.ZodTypeAny, {
77
77
  count: number;
78
+ max: number;
78
79
  mean: number;
79
80
  m2: number;
80
- max: number;
81
81
  }, {
82
82
  count: number;
83
+ max: number;
83
84
  mean: number;
84
85
  m2: number;
85
- max: number;
86
86
  }>;
87
87
  consoleErrors: z.ZodObject<{
88
88
  count: z.ZodNumber;
@@ -91,64 +91,64 @@ export declare const RouteEnvelopeSchema: z.ZodObject<{
91
91
  max: z.ZodNumber;
92
92
  }, "strip", z.ZodTypeAny, {
93
93
  count: number;
94
+ max: number;
94
95
  mean: number;
95
96
  m2: number;
96
- max: number;
97
97
  }, {
98
98
  count: number;
99
+ max: number;
99
100
  mean: number;
100
101
  m2: number;
101
- max: number;
102
102
  }>;
103
103
  }, "strip", z.ZodTypeAny, {
104
104
  durationMs: {
105
105
  count: number;
106
+ max: number;
106
107
  mean: number;
107
108
  m2: number;
108
- max: number;
109
109
  };
110
110
  net: {
111
111
  count: number;
112
+ max: number;
112
113
  mean: number;
113
114
  m2: number;
114
- max: number;
115
115
  };
116
116
  consoleErrors: {
117
117
  count: number;
118
+ max: number;
118
119
  mean: number;
119
120
  m2: number;
120
- max: number;
121
121
  };
122
122
  netErrors: {
123
123
  count: number;
124
+ max: number;
124
125
  mean: number;
125
126
  m2: number;
126
- max: number;
127
127
  };
128
128
  }, {
129
129
  durationMs: {
130
130
  count: number;
131
+ max: number;
131
132
  mean: number;
132
133
  m2: number;
133
- max: number;
134
134
  };
135
135
  net: {
136
136
  count: number;
137
+ max: number;
137
138
  mean: number;
138
139
  m2: number;
139
- max: number;
140
140
  };
141
141
  consoleErrors: {
142
142
  count: number;
143
+ max: number;
143
144
  mean: number;
144
145
  m2: number;
145
- max: number;
146
146
  };
147
147
  netErrors: {
148
148
  count: number;
149
+ max: number;
149
150
  mean: number;
150
151
  m2: number;
151
- max: number;
152
152
  };
153
153
  }>;
154
154
  }, "strip", z.ZodTypeAny, {
@@ -157,27 +157,27 @@ export declare const RouteEnvelopeSchema: z.ZodObject<{
157
157
  stats: {
158
158
  durationMs: {
159
159
  count: number;
160
+ max: number;
160
161
  mean: number;
161
162
  m2: number;
162
- max: number;
163
163
  };
164
164
  net: {
165
165
  count: number;
166
+ max: number;
166
167
  mean: number;
167
168
  m2: number;
168
- max: number;
169
169
  };
170
170
  consoleErrors: {
171
171
  count: number;
172
+ max: number;
172
173
  mean: number;
173
174
  m2: number;
174
- max: number;
175
175
  };
176
176
  netErrors: {
177
177
  count: number;
178
+ max: number;
178
179
  mean: number;
179
180
  m2: number;
180
- max: number;
181
181
  };
182
182
  };
183
183
  }, {
@@ -186,27 +186,27 @@ export declare const RouteEnvelopeSchema: z.ZodObject<{
186
186
  stats: {
187
187
  durationMs: {
188
188
  count: number;
189
+ max: number;
189
190
  mean: number;
190
191
  m2: number;
191
- max: number;
192
192
  };
193
193
  net: {
194
194
  count: number;
195
+ max: number;
195
196
  mean: number;
196
197
  m2: number;
197
- max: number;
198
198
  };
199
199
  consoleErrors: {
200
200
  count: number;
201
+ max: number;
201
202
  mean: number;
202
203
  m2: number;
203
- max: number;
204
204
  };
205
205
  netErrors: {
206
206
  count: number;
207
+ max: number;
207
208
  mean: number;
208
209
  m2: number;
209
- max: number;
210
210
  };
211
211
  };
212
212
  }>;
@@ -111,7 +111,7 @@ export interface GateContext {
111
111
  * Gate an enterprise feature. No-op in dev/eval (requireLicense:false). In production it throws
112
112
  * EnterpriseLicenseError unless a valid, unexpired key that covers `feature` is present.
113
113
  */
114
- export declare function assertEnterprise(feature: string, ctx: GateContext): void;
114
+ export declare function assertEnterprise(feature: string, ctx: GateContext, env?: NodeJS.ProcessEnv, baked?: string): void;
115
115
  /** Env names that carry the activation: the operator's key, and the issuer public key baked at release. */
116
116
  export declare const LICENSE_KEY_ENV = "RETICLE_LICENSE_KEY";
117
117
  export declare const LICENSE_PUBLIC_KEY_ENV = "RETICLE_LICENSE_PUBLIC_KEY";
@@ -131,7 +131,13 @@ export interface LicenseReport {
131
131
  * neither, so it told an interested reader to set a variable without saying what it would unlock or
132
132
  * how to obtain it. A dead end at exactly the moment somebody is asking.
133
133
  */
134
- gated: readonly string[];
134
+ /**
135
+ * What a licence unlocks in this build. Present on every branch a PROSPECTIVE customer lands on,
136
+ * because being told to set a variable with no idea what it unlocks is the dead end this exists to
137
+ * close. Absent once a licence is `active`: that reader has already bought, so naming features at
138
+ * them reveals the product surface and answers nothing they asked.
139
+ */
140
+ gated?: readonly string[];
135
141
  contact: string;
136
142
  /**
137
143
  * The key is valid, and scoped to features this build does not gate, so it unlocks nothing here.
@@ -114,26 +114,18 @@ export class EnterpriseLicenseError extends Error {
114
114
  this.reason = reason;
115
115
  }
116
116
  }
117
- /** The issuer public key from the environment (set at release / by the operator); undefined if unset. */
118
- function issuerPublicKey() {
119
- const pem = process.env[LICENSE_PUBLIC_KEY_ENV];
120
- if (pem === undefined || 0 === pem.length)
121
- return undefined;
122
- try {
123
- return createPublicKey(pem);
124
- }
125
- catch {
126
- return undefined;
127
- }
128
- }
129
117
  /**
130
118
  * Gate an enterprise feature. No-op in dev/eval (requireLicense:false). In production it throws
131
119
  * EnterpriseLicenseError unless a valid, unexpired key that covers `feature` is present.
132
120
  */
133
- export function assertEnterprise(feature, ctx) {
121
+ export function assertEnterprise(feature, ctx, env = process.env, baked) {
134
122
  if (!ctx.requireLicense)
135
123
  return;
136
- const publicKey = ctx.publicKey ?? issuerPublicKey();
124
+ // Resolved exactly as the env-entry point resolves it: BAKED first, env only as the dev/test
125
+ // hatch. This path used to read the environment alone, so on a real release, where the key is
126
+ // baked and no env var is set, it denied every valid licence with `no-issuer-key` while
127
+ // assertEnterpriseFromEnv allowed the same key on the same build. Two gates, one truth.
128
+ const publicKey = ctx.publicKey ?? loadPublicKey(resolveIssuerPublicKeyPem(env, baked));
137
129
  if (publicKey === undefined)
138
130
  throw new EnterpriseLicenseError(feature, 'no-issuer-key');
139
131
  const check = verifyLicenseKey(ctx.key, publicKey, ctx.now());
@@ -225,7 +217,10 @@ export function describeLicense(now, env = process.env, baked = BAKED_ISSUER_PUB
225
217
  ? `licensed to ${org} (${plan}), expires ${new Date(exp).toISOString()}. This key covers ${features?.join(', ')}, and nothing this build gates (${LICENSE_OFFER.gated.join(', ')}) is included. Contact ${LICENSE_CONTACT}`
226
218
  : `licensed to ${org} (${plan}), expires ${new Date(exp).toISOString()}`;
227
219
  return {
228
- ...LICENSE_OFFER,
220
+ contact: LICENSE_CONTACT,
221
+ // The one active case that still needs the list: a key covering nothing this build gates is
222
+ // reported here, and the sentence explaining it is unreadable without both sides named.
223
+ ...(coversNothingHere ? { gated: LICENSE_OFFER.gated } : {}),
229
224
  status: LicenseActivation.ACTIVE,
230
225
  licenseId: lid,
231
226
  org,
@@ -0,0 +1,83 @@
1
+ import { type ProjectCandidate, type ProjectRegistry } from '@reticlehq/core';
2
+ import type { ConfigDiscovery } from '../cli/config-discovery.js';
3
+ /**
4
+ * Which `.reticle/` a session's artifacts belong in.
5
+ *
6
+ * Everything Reticle persists — flows, the capability contract, baselines, capsules, the cross-run
7
+ * project memory — used to resolve against the DAEMON's own `process.cwd()`. That is a statement
8
+ * about where the daemon was launched and it was being read as a statement about which project is
9
+ * being verified. Those are not the same thing and are usually not even the same tree: a user-scoped
10
+ * MCP registration is the common case, and the editor that spawns it starts it wherever it likes.
11
+ *
12
+ * Three field shapes, one defect: `cwd=/` gave `ENOENT: mkdir '/.reticle'`; a daemon started in
13
+ * project A wrote project B's flow into A's checkout and reported success without naming the path;
14
+ * and `verify_change` could only answer "unknown" because no flow could be persisted for it to match
15
+ * against.
16
+ *
17
+ * ## Two sources, one rule
18
+ *
19
+ * Candidates arrive from config discovery (which walks out from the daemon's own directory) and from
20
+ * the user-level project registry (which `init` writes, and which reaches checkouts discovery cannot
21
+ * see). This function takes the merged list and does not know or care which source an entry came
22
+ * from — a privileged source would be a second rule, and two rules for one question is how the
23
+ * answers start disagreeing.
24
+ *
25
+ * ## Why matching on projectId, and not a new wire field
26
+ *
27
+ * HELLO already stamps `projectId`, and `.reticle.json` already declares the same id sitting next to
28
+ * the code it configures. The join is therefore available today, for every SDK already in the field.
29
+ * Adding a `root` to HELLO would move the wire contract, strand every older SDK on the fallback, and
30
+ * buy nothing this does not already give.
31
+ *
32
+ * ## Why it can decline to answer
33
+ *
34
+ * Every non-matching branch returns the daemon root and SAYS which branch it was. A wrong root is
35
+ * worse than no root — it writes a caller's evidence into a tree they never drove — so the ambiguous
36
+ * case refuses rather than picks, exactly as `discoverProjectConfigs` refuses to pick one config.
37
+ * The reason travels with the answer so callers can report the surprise instead of hiding it.
38
+ */
39
+ /** Why the root below is the root. Travels with the answer so a caller can say what happened. */
40
+ export declare const ArtifactRootReason: {
41
+ /** Exactly one discovered config declares this session's project. The good case. */
42
+ readonly MATCHED_PROJECT: "matched-project";
43
+ /** The session declared no projectId — a pre-2.0 SDK. Nothing to match on. */
44
+ readonly NO_PROJECT_ID: "no-project-id";
45
+ /** The search ran and nothing it found declares this project. */
46
+ readonly NO_MATCH: "no-match";
47
+ /** Two or more checkouts declare this project. Refused rather than guessed. */
48
+ readonly AMBIGUOUS: "ambiguous";
49
+ };
50
+ export type ArtifactRootReason = (typeof ArtifactRootReason)[keyof typeof ArtifactRootReason];
51
+ export interface ArtifactRootQuery {
52
+ /** The connected session's HELLO projectId, when it sent one. */
53
+ projectId: string | undefined;
54
+ /** Every project this machine knows about. Supplied, not gathered here — this stays pure. */
55
+ candidates: readonly ProjectCandidate[];
56
+ /** Where artifacts go when the project cannot be identified. Already a `.reticle` path. */
57
+ daemonRoot: string;
58
+ }
59
+ export interface ArtifactRoot {
60
+ /** Absolute path to the `.reticle` directory. Always present, on every branch. */
61
+ root: string;
62
+ reason: ArtifactRootReason;
63
+ /**
64
+ * The competing project directories, on `AMBIGUOUS` only. A caller that has to explain the refusal
65
+ * needs to name them; a caller that does not can ignore the field.
66
+ */
67
+ candidates?: string[];
68
+ }
69
+ /**
70
+ * Resolve the artifact root for one session.
71
+ *
72
+ * Pure: no IO, no cwd, no clock. The filesystem walk belongs to `discoverProjectConfigs` and is
73
+ * passed in, which is what makes every branch below testable without a fixture tree.
74
+ */
75
+ export declare function resolveArtifactRoot(query: ArtifactRootQuery): ArtifactRoot;
76
+ /**
77
+ * Everything this machine knows about where projects live, from both sources.
78
+ *
79
+ * Discovery first, deliberately: it read a `.reticle.json` that exists RIGHT NOW, while the registry
80
+ * is a cache of something `init` saw once. Order only decides which duplicate survives dedupe, and
81
+ * the one confirmed a moment ago is the better survivor.
82
+ */
83
+ export declare function projectCandidatesFrom(discovery: ConfigDiscovery, registry: ProjectRegistry): ProjectCandidate[];
@@ -0,0 +1,112 @@
1
+ import { join } from 'node:path';
2
+ import { ReticleDir, projectCandidates, } from '@reticlehq/core';
3
+ /**
4
+ * Which `.reticle/` a session's artifacts belong in.
5
+ *
6
+ * Everything Reticle persists — flows, the capability contract, baselines, capsules, the cross-run
7
+ * project memory — used to resolve against the DAEMON's own `process.cwd()`. That is a statement
8
+ * about where the daemon was launched and it was being read as a statement about which project is
9
+ * being verified. Those are not the same thing and are usually not even the same tree: a user-scoped
10
+ * MCP registration is the common case, and the editor that spawns it starts it wherever it likes.
11
+ *
12
+ * Three field shapes, one defect: `cwd=/` gave `ENOENT: mkdir '/.reticle'`; a daemon started in
13
+ * project A wrote project B's flow into A's checkout and reported success without naming the path;
14
+ * and `verify_change` could only answer "unknown" because no flow could be persisted for it to match
15
+ * against.
16
+ *
17
+ * ## Two sources, one rule
18
+ *
19
+ * Candidates arrive from config discovery (which walks out from the daemon's own directory) and from
20
+ * the user-level project registry (which `init` writes, and which reaches checkouts discovery cannot
21
+ * see). This function takes the merged list and does not know or care which source an entry came
22
+ * from — a privileged source would be a second rule, and two rules for one question is how the
23
+ * answers start disagreeing.
24
+ *
25
+ * ## Why matching on projectId, and not a new wire field
26
+ *
27
+ * HELLO already stamps `projectId`, and `.reticle.json` already declares the same id sitting next to
28
+ * the code it configures. The join is therefore available today, for every SDK already in the field.
29
+ * Adding a `root` to HELLO would move the wire contract, strand every older SDK on the fallback, and
30
+ * buy nothing this does not already give.
31
+ *
32
+ * ## Why it can decline to answer
33
+ *
34
+ * Every non-matching branch returns the daemon root and SAYS which branch it was. A wrong root is
35
+ * worse than no root — it writes a caller's evidence into a tree they never drove — so the ambiguous
36
+ * case refuses rather than picks, exactly as `discoverProjectConfigs` refuses to pick one config.
37
+ * The reason travels with the answer so callers can report the surprise instead of hiding it.
38
+ */
39
+ /** Why the root below is the root. Travels with the answer so a caller can say what happened. */
40
+ export const ArtifactRootReason = {
41
+ /** Exactly one discovered config declares this session's project. The good case. */
42
+ MATCHED_PROJECT: 'matched-project',
43
+ /** The session declared no projectId — a pre-2.0 SDK. Nothing to match on. */
44
+ NO_PROJECT_ID: 'no-project-id',
45
+ /** The search ran and nothing it found declares this project. */
46
+ NO_MATCH: 'no-match',
47
+ /** Two or more checkouts declare this project. Refused rather than guessed. */
48
+ AMBIGUOUS: 'ambiguous',
49
+ };
50
+ /**
51
+ * Resolve the artifact root for one session.
52
+ *
53
+ * Pure: no IO, no cwd, no clock. The filesystem walk belongs to `discoverProjectConfigs` and is
54
+ * passed in, which is what makes every branch below testable without a fixture tree.
55
+ */
56
+ export function resolveArtifactRoot(query) {
57
+ const { projectId, candidates, daemonRoot } = query;
58
+ if (projectId === undefined || 0 === projectId.length) {
59
+ return { root: daemonRoot, reason: ArtifactRootReason.NO_PROJECT_ID };
60
+ }
61
+ const matches = dedupeByDirectory(candidates.filter((c) => c.projectId === projectId));
62
+ if (0 === matches.length) {
63
+ return { root: daemonRoot, reason: ArtifactRootReason.NO_MATCH };
64
+ }
65
+ if (matches.length > 1) {
66
+ return {
67
+ root: daemonRoot,
68
+ reason: ArtifactRootReason.AMBIGUOUS,
69
+ candidates: matches.map((config) => config.directory),
70
+ };
71
+ }
72
+ const [only] = matches;
73
+ // `matches.length === 1` above, so this is defined — but the codebase forbids `!`, and a default
74
+ // that can never be taken is cheaper than the alternative spelling.
75
+ const directory = only?.directory ?? '';
76
+ if (0 === directory.length) {
77
+ return { root: daemonRoot, reason: ArtifactRootReason.NO_MATCH };
78
+ }
79
+ return { root: join(directory, ReticleDir.ROOT), reason: ArtifactRootReason.MATCHED_PROJECT };
80
+ }
81
+ /**
82
+ * One entry per directory.
83
+ *
84
+ * Discovery and the registry routinely name the SAME checkout — the registry remembers what `init`
85
+ * wrote, and discovery finds that same file whenever the daemon happens to be in the tree. Counting
86
+ * it twice would read as two competing checkouts and make the resolver refuse, which is the one
87
+ * outcome worse than either source alone.
88
+ */
89
+ function dedupeByDirectory(matches) {
90
+ const seen = new Set();
91
+ const out = [];
92
+ for (const candidate of matches) {
93
+ if (seen.has(candidate.directory))
94
+ continue;
95
+ seen.add(candidate.directory);
96
+ out.push(candidate);
97
+ }
98
+ return out;
99
+ }
100
+ /**
101
+ * Everything this machine knows about where projects live, from both sources.
102
+ *
103
+ * Discovery first, deliberately: it read a `.reticle.json` that exists RIGHT NOW, while the registry
104
+ * is a cache of something `init` saw once. Order only decides which duplicate survives dedupe, and
105
+ * the one confirmed a moment ago is the better survivor.
106
+ */
107
+ export function projectCandidatesFrom(discovery, registry) {
108
+ const discovered = discovery.found.flatMap((config) => config.projectId === undefined || 0 === config.projectId.length
109
+ ? []
110
+ : [{ projectId: config.projectId, directory: config.directory }]);
111
+ return [...discovered, ...projectCandidates(registry)];
112
+ }
@@ -0,0 +1,9 @@
1
+ import type { FileSystemPort } from './fs-port.js';
2
+ export interface MemoryFs {
3
+ fs: FileSystemPort;
4
+ /** Every file written, keyed by normalised absolute path. */
5
+ written: Map<string, string>;
6
+ /** Every directory created. */
7
+ dirs: Set<string>;
8
+ }
9
+ export declare function createMemoryFs(): MemoryFs;