@reticlehq/server 2.5.0 → 2.6.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 (147) hide show
  1. package/SKILL.md +27 -10
  2. package/dist/bridge/bridge.d.ts +12 -0
  3. package/dist/bridge/bridge.js +32 -2
  4. package/dist/bridge/bridge.test-harness.d.ts +8 -0
  5. package/dist/bridge/bridge.test-harness.js +9 -1
  6. package/dist/cli/cli-doctor.d.ts +0 -5
  7. package/dist/cli/cli-doctor.js +71 -3
  8. package/dist/cli/cli-parse-feedback.d.ts +31 -0
  9. package/dist/cli/cli-parse-feedback.js +56 -0
  10. package/dist/cli/cli-parse.d.ts +5 -9
  11. package/dist/cli/cli-parse.js +7 -23
  12. package/dist/cli/doctor-daemon-line.d.ts +37 -0
  13. package/dist/cli/doctor-daemon-line.js +35 -0
  14. package/dist/cli/port-holder.d.ts +43 -0
  15. package/dist/cli/port-holder.js +99 -0
  16. package/dist/cli.js +131 -11
  17. package/dist/daemon/daemon-resilience.d.ts +24 -0
  18. package/dist/daemon/daemon-resilience.js +105 -17
  19. package/dist/daemon/daemon.js +26 -1
  20. package/dist/daemon/heartbeat.d.ts +115 -0
  21. package/dist/daemon/heartbeat.js +141 -0
  22. package/dist/daemon/idle-shutdown.d.ts +13 -5
  23. package/dist/daemon/idle-shutdown.js +14 -6
  24. package/dist/daemon/port-presence.d.ts +76 -0
  25. package/dist/daemon/port-presence.js +93 -0
  26. package/dist/daemon/state-dir.d.ts +7 -0
  27. package/dist/daemon/state-dir.js +59 -0
  28. package/dist/daemon/unclean-predecessor.d.ts +23 -0
  29. package/dist/daemon/unclean-predecessor.js +27 -0
  30. package/dist/events/predicate-eval.d.ts +10 -0
  31. package/dist/events/predicate-eval.js +24 -4
  32. package/dist/events/predicate-parse.d.ts +23 -0
  33. package/dist/events/predicate-parse.js +54 -0
  34. package/dist/events/predicate.d.ts +5 -0
  35. package/dist/events/predicate.js +17 -3
  36. package/dist/events/ring-buffer.js +1 -16
  37. package/dist/flows/flow-replay.d.ts +1 -1
  38. package/dist/flows/flow-replay.js +19 -2
  39. package/dist/flows/flow-step-runners.js +2 -3
  40. package/dist/flows/flow-tools.js +10 -14
  41. package/dist/flows/role-drift-reason.d.ts +20 -0
  42. package/dist/flows/role-drift-reason.js +39 -0
  43. package/dist/flows/suite-flakes.d.ts +33 -0
  44. package/dist/flows/suite-flakes.js +32 -0
  45. package/dist/honesty/blind-spots.d.ts +19 -0
  46. package/dist/honesty/blind-spots.js +33 -0
  47. package/dist/honesty/verified.d.ts +10 -1
  48. package/dist/honesty/verified.js +16 -0
  49. package/dist/http-server.js +9 -0
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.js +10 -1
  52. package/dist/init/agent-rules.d.ts +18 -0
  53. package/dist/init/agent-rules.js +22 -4
  54. package/dist/init/cra.d.ts +13 -0
  55. package/dist/init/cra.js +16 -0
  56. package/dist/init/detect.d.ts +6 -0
  57. package/dist/init/detect.js +8 -0
  58. package/dist/init/mcp-clients.d.ts +81 -0
  59. package/dist/init/mcp-clients.js +248 -0
  60. package/dist/init/node-io.js +8 -2
  61. package/dist/init/plan-framework.js +36 -2
  62. package/dist/init/plan.d.ts +14 -0
  63. package/dist/init/plan.js +73 -9
  64. package/dist/init/run.d.ts +18 -1
  65. package/dist/init/run.js +133 -16
  66. package/dist/init/windows-quote.d.ts +38 -0
  67. package/dist/init/windows-quote.js +70 -0
  68. package/dist/mcp/mcp-outage.d.ts +1 -0
  69. package/dist/mcp/mcp-outage.js +8 -1
  70. package/dist/mcp/mcp-proxy.d.ts +11 -1
  71. package/dist/mcp/mcp-proxy.js +30 -7
  72. package/dist/mcp/mcp.js +29 -2
  73. package/dist/pool/browser-pool.d.ts +9 -0
  74. package/dist/pool/browser-pool.js +38 -9
  75. package/dist/session/attachment-history.d.ts +46 -0
  76. package/dist/session/attachment-history.js +65 -0
  77. package/dist/session/no-session-diagnosis.d.ts +7 -0
  78. package/dist/session/no-session-diagnosis.js +53 -3
  79. package/dist/session/no-session-watch.d.ts +3 -1
  80. package/dist/session/no-session-watch.js +10 -2
  81. package/dist/session/pending-commands.js +1 -0
  82. package/dist/session/session-info.d.ts +17 -1
  83. package/dist/session/session-info.js +5 -1
  84. package/dist/session/session-manager.d.ts +3 -0
  85. package/dist/session/session-manager.js +72 -3
  86. package/dist/session/session.d.ts +1 -0
  87. package/dist/session/session.js +8 -0
  88. package/dist/telemetry/crash-cause.d.ts +47 -0
  89. package/dist/telemetry/crash-cause.js +104 -0
  90. package/dist/telemetry/daemon-telemetry.d.ts +25 -7
  91. package/dist/telemetry/daemon-telemetry.js +28 -13
  92. package/dist/telemetry/error-class.d.ts +26 -0
  93. package/dist/telemetry/error-class.js +52 -0
  94. package/dist/telemetry/feedback-cli.d.ts +7 -2
  95. package/dist/telemetry/feedback-cli.js +49 -5
  96. package/dist/telemetry/feedback-context.d.ts +1 -6
  97. package/dist/telemetry/feedback-context.js +64 -4
  98. package/dist/telemetry/git-facts.d.ts +7 -0
  99. package/dist/telemetry/git-facts.js +4 -4
  100. package/dist/telemetry/project-profile.js +2 -1
  101. package/dist/telemetry/session-metrics.d.ts +45 -3
  102. package/dist/telemetry/session-metrics.js +247 -9
  103. package/dist/telemetry/telemetry.d.ts +19 -1
  104. package/dist/telemetry/telemetry.js +31 -8
  105. package/dist/tools/act-tools.js +33 -6
  106. package/dist/tools/assert-verdict.d.ts +7 -1
  107. package/dist/tools/assert-verdict.js +15 -3
  108. package/dist/tools/browser-tools.js +8 -2
  109. package/dist/tools/contract-tools.d.ts +0 -5
  110. package/dist/tools/contract-tools.js +26 -0
  111. package/dist/tools/dynamic-tools.js +6 -1
  112. package/dist/tools/error-recovery.d.ts +14 -0
  113. package/dist/tools/error-recovery.js +83 -7
  114. package/dist/tools/feedback-invite.d.ts +48 -0
  115. package/dist/tools/feedback-invite.js +71 -0
  116. package/dist/tools/invoke-tool.d.ts +0 -6
  117. package/dist/tools/invoke-tool.js +69 -1
  118. package/dist/tools/navigate-arrival.d.ts +26 -0
  119. package/dist/tools/navigate-arrival.js +60 -0
  120. package/dist/tools/navigate-result.d.ts +15 -1
  121. package/dist/tools/navigate-result.js +17 -10
  122. package/dist/tools/observe-tools.js +22 -4
  123. package/dist/tools/real-input-attempt.js +10 -5
  124. package/dist/tools/tool-kit.d.ts +8 -0
  125. package/dist/tools/tool-surface.js +13 -0
  126. package/docs/README.md +55 -0
  127. package/docs/agent-cheatsheet.md +2 -2
  128. package/docs/benchmarks.md +1 -1
  129. package/docs/fixtures-dispatch-receiver.yml +76 -0
  130. package/docs/fixtures.md +17 -0
  131. package/docs/gate-plan.md +98 -0
  132. package/docs/gates.md +117 -0
  133. package/docs/local-registry.md +1 -1
  134. package/docs/matrix/2.5.0/claude-code-machine.json +19 -0
  135. package/docs/matrix/2.5.0/codex-machine.json +20 -0
  136. package/docs/matrix/2.5.0/cursor-machine.json +22 -0
  137. package/docs/matrix/2.5.0/gemini-machine.json +22 -0
  138. package/docs/matrix/2.5.0/opencode-machine.json +22 -0
  139. package/docs/matrix/2.5.0/vscode-machine.json +22 -0
  140. package/docs/matrix/2.5.0/windsurf-machine.json +22 -0
  141. package/docs/matrix/MATRIX.md +26 -0
  142. package/docs/matrix/README.md +59 -0
  143. package/docs/mint.json +1 -1
  144. package/docs/system-map.md +152 -0
  145. package/docs/telemetry-contract.md +2 -2
  146. package/docs/telemetry.md +7 -0
  147. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -66,6 +66,10 @@ export { BrowserPool, DEFAULT_LEASE_TTL_MS } from './pool/browser-pool.js';
66
66
  export { playwrightLauncher, resolveMaxContexts } from './pool/playwright-launcher.js';
67
67
  export { appendReticleParams } from './tools/lease-tools.js';
68
68
  export { writePid, removePid, isRunning, logPath, readPid, isAlive } from './daemon/daemon.js';
69
+ // The daemon's own liveness vocabulary, exported so a GATE can read a daemon log back and say how
70
+ // that daemon ended. Without this the battery would re-implement the rule, and a guard that
71
+ // re-implements what it guards is insensitive to it.
72
+ export { classifyDaemonLife, DaemonEnd, DAEMON_HEARTBEAT_EVENT, DAEMON_HEARTBEAT_MS, } from './daemon/heartbeat.js';
69
73
  export { scrollToFind } from './input/scroll-find.js';
70
74
  export { CORE_TOOL_NAMES, TOOL_SURFACE, TOOL_PROFILE_ENV, filterTools, resolveToolSurface, } from './tools/tool-surface.js';
71
75
  export { AnnotationStore } from './flows/annotation-store.js';
@@ -262,6 +266,9 @@ export async function start(options = {}) {
262
266
  reticleRoot,
263
267
  now,
264
268
  bridgePort: port,
269
+ // The daemon's OWN project, so a tool can tell "this session is mine" from "this session
270
+ // belongs to a sibling app under the same daemon". contract_save refuses on the second.
271
+ ...(activeProjectId === undefined ? {} : { projectId: activeProjectId }),
265
272
  };
266
273
  const profile = resolveToolSurface(options.toolProfile);
267
274
  const server = createMcpServer(realInput !== undefined ? { ...deps, realInput } : deps, profile);
@@ -321,7 +328,9 @@ export async function startDaemon(options = {}) {
321
328
  // never picked when the agent omits a sessionId. Explicit per-call scope/sessionId still overrides.
322
329
  // Scope + the no-session diagnosis: "no browser session connected" is the error that ends most
323
330
  // sessions, and the agent is told to check two things it cannot see. See no-session-diagnosis.ts.
324
- wireSessionScope(bridge.sessions, readProjectId(process.cwd()), port);
331
+ // The pool reader is passed lazily: `pool` is assigned above but may be undefined when this
332
+ // daemon runs without one, and the diagnosis must degrade to the tab message rather than lie.
333
+ wireSessionScope(bridge.sessions, readProjectId(process.cwd()), port, () => pool?.reapedLeaseCount() ?? 0);
325
334
  const { realInput, owned } = await resolveRealInput(options, () => shared.close(), makeNetworkDetailRouter(bridge, options.driveUrl));
326
335
  const fs = createNodeFileSystem();
327
336
  const reticleRoot = options.reticleRoot ?? join(process.cwd(), ReticleDir.ROOT);
@@ -4,6 +4,13 @@
4
4
  * it with reticle". Registering the MCP server (mcp.ts / cursor.ts) only makes the tools AVAILABLE;
5
5
  * this is the piece that makes the agent reach for them, every session, unprompted.
6
6
  *
7
+ * It carries a SECOND standing instruction, and that one is not about this project at all: send
8
+ * feedback to the Reticle maintainers, from the moment instrumentation starts. The agent is the party
9
+ * that experiences Reticle, and the only one who can say what it cost — and by the time anyone would
10
+ * think to ask, the context that knew is gone. Written into the same block, because the moment the
11
+ * rule lands (init, before any framework wiring) is the moment the setup itself is being lived
12
+ * through, which is when the reports we hear least about are actually generated.
13
+ *
7
14
  * The rule is framed as a definition-of-done (WHEN to verify, HOW, and the anti-reward-hacking guard),
8
15
  * written into whichever instruction file the detected agent reads: `CLAUDE.md` (Claude Code),
9
16
  * `.cursor/rules/reticle.mdc` (Cursor, `alwaysApply` so it stays in context), or `AGENTS.md` as the
@@ -15,6 +22,17 @@ export declare const CLAUDE_MD_PATH = "CLAUDE.md";
15
22
  export declare const AGENTS_MD_PATH = "AGENTS.md";
16
23
  /** Cursor project rule; the frontmatter `alwaysApply` keeps it in context on every turn. */
17
24
  export declare const CURSOR_RULE_PATH = ".cursor/rules/reticle.mdc";
25
+ /**
26
+ * How the agent must INVOKE the CLI.
27
+ *
28
+ * `reticle init` installs the SDK packages, never the server — so there is no `reticle` on the
29
+ * agent's PATH, and every bare `reticle <cmd>` this rule named was an instruction that could not be
30
+ * followed. Reported from a field sweep: three of them, in the file the agent re-reads every session.
31
+ *
32
+ * `npx @reticlehq/server` is the same invocation the MCP registration uses, and it is derived from
33
+ * the package's own name so the two can never drift apart.
34
+ */
35
+ export declare const CLI: string;
18
36
  export declare const AgentRuleStatus: {
19
37
  readonly APPLY: "apply";
20
38
  readonly ALREADY: "already";
@@ -4,6 +4,13 @@
4
4
  * it with reticle". Registering the MCP server (mcp.ts / cursor.ts) only makes the tools AVAILABLE;
5
5
  * this is the piece that makes the agent reach for them, every session, unprompted.
6
6
  *
7
+ * It carries a SECOND standing instruction, and that one is not about this project at all: send
8
+ * feedback to the Reticle maintainers, from the moment instrumentation starts. The agent is the party
9
+ * that experiences Reticle, and the only one who can say what it cost — and by the time anyone would
10
+ * think to ask, the context that knew is gone. Written into the same block, because the moment the
11
+ * rule lands (init, before any framework wiring) is the moment the setup itself is being lived
12
+ * through, which is when the reports we hear least about are actually generated.
13
+ *
7
14
  * The rule is framed as a definition-of-done (WHEN to verify, HOW, and the anti-reward-hacking guard),
8
15
  * written into whichever instruction file the detected agent reads: `CLAUDE.md` (Claude Code),
9
16
  * `.cursor/rules/reticle.mdc` (Cursor, `alwaysApply` so it stays in context), or `AGENTS.md` as the
@@ -30,7 +37,7 @@ const RULE_END = '<!-- reticle:end -->';
30
37
  * `npx @reticlehq/server` is the same invocation the MCP registration uses, and it is derived from
31
38
  * the package's own name so the two can never drift apart.
32
39
  */
33
- const CLI = `${NPX} ${RETICLE_NPM_PACKAGE}`;
40
+ export const CLI = `${NPX} ${RETICLE_NPM_PACKAGE}`;
34
41
  /** The rule body (markdown). States WHEN to verify, HOW, and never-weaken-a-check. */
35
42
  const RULE_BODY = `## Verifying with Reticle
36
43
 
@@ -43,8 +50,19 @@ This app is instrumented by **Reticle** — an in-app verification layer exposed
43
50
  - **If Reticle can't run** (no daemon, or this is not a running web app), say so — do not skip verification silently.
44
51
  - **If a tool result carries \`version_skew\`, act on it before trusting anything else.** Two pieces of Reticle are on different wire contracts, so tools can misbehave in ways neither side reports — a green here is not evidence. The field says which pair disagrees and names the exact fix: run \`${CLI} stop\` and retry when the DAEMON is stale, or tell the human to install the matching SDK and restart their dev server when the PAGE is. Do that first, then redo whatever you were verifying.
45
52
  - **If a tool result carries \`update_available\`, say so.** Reticle checked npm and a newer version exists. Tell the human the version numbers and offer to run \`${CLI} update\` — between tasks, never mid-verification, because it restarts the daemon and drops the session. Finish what you are doing first.
46
- - **Wish Reticle could do something? Ask for it with \`reticle_feedback\`.** Reticle is built for agents, so what you find awkward or missing is what decides the roadmap — and you are the only one who can report it, because that thought disappears when your context does. Put the report itself in \`text\` and pick a \`kind\` — both are required: \`feature_request\` for something that does not exist, \`improvement\` for something that works but costs too many calls, \`bug\` for a tool that returned something wrong, \`gap\` for something Reticle could not observe at all, \`ambiguity\` for a verdict you could not act on. Say what you are actually trying to achieve (\`need\`) and how you work around it today (\`currentApproach\`), and set \`model\` to the model you are running. File it the moment you notice, then carry on — nothing is too minor.
47
- - **If Reticle itself fails you, tell its maintainers with \`reticle_feedback\`.** A tool that returned something wrong, a thing Reticle could not observe at all, or a verdict you could not act on — file it as \`text\` — a root-cause analysis with the call trace — under \`kind: bug\` (wrong result), \`gap\` (could not observe it) or \`ambiguity\` (undecidable verdict), then carry on with the task. Working around a broken tool in silence is how the same defect survives. This is about defects in *Reticle*; a bug you find in *this app* is Reticle working, and belongs in your report to the user instead.`;
53
+
54
+ ## Sending feedback to the Reticle team (first-class)
55
+
56
+ **Reporting your experience with Reticle is part of using Reticle, from the moment you start wiring it in — not something to do at the end if anything went wrong.** Reticle is built FOR agents, which makes you its actual user and the only one who can see what it costs you. That knowledge is gone when your context is, so it has to leave the turn it happened in.
57
+
58
+ - **Send it with \`reticle_feedback\`, the moment you notice.** One call, then carry straight on with your task. Do not batch reports, do not wait until the task is done, and do not decide something is too minor — the small awkward things are what nobody ever reports, so they never get fixed.
59
+ - **Everything counts, in both directions.** A tool that returned something wrong (\`bug\`), something Reticle could not observe at all (\`gap\`), a verdict you could not act on (\`ambiguity\`), something you wished existed (\`feature_request\`), something that works but costs too many calls or has an awkward shape (\`improvement\`).
60
+ - **Setup and installation count too, and are the easiest to lose.** Anything that went wrong while installing, wiring, or starting Reticle — a step in the docs that did not match reality, a plugin that had to be added by hand, a daemon that would not start, an OS-specific detour. You hit these before any verification runs, which is exactly why they usually go unreported.
61
+ - **When the tools are not reachable yet**, file the same report from the shell: \`${CLI} feedback --agent --kind <bug|gap|ambiguity|feature_request|improvement> "what happened"\`. It needs no daemon and no working install, so a Reticle that broke before it started can still be reported.
62
+ - **Write it so it can be acted on.** For a failure: what you called, what you expected, what you got, and the call trail in \`trace\`. For a request: the GOAL in \`need\` (not your guess at the solution), what improves in \`impact\`, and how you work around it today in \`currentApproach\` — that workaround is usually the most useful line in the report. Set \`model\` to the model you are running.
63
+ - **Working around a Reticle defect in silence is the one thing not to do.** A silent workaround is how the same defect survives to the next user.
64
+ - **Report defects in *Reticle*, not in the app under test.** A bug you find in this app is Reticle working as intended; that belongs in your report to the user.
65
+ - **Never include app source, secrets, user data, or customer records** in a report.`;
48
66
  export const AgentRuleStatus = {
49
67
  APPLY: 'apply',
50
68
  ALREADY: 'already',
@@ -96,7 +114,7 @@ function refreshMarkedBlock(existing) {
96
114
  /** The Cursor rule file (.mdc): `alwaysApply` keeps the rule in every turn's context. */
97
115
  export function cursorRuleFile() {
98
116
  return `---
99
- description: Verify web features with Reticle after building them
117
+ description: Verify web features with Reticle after building them, and report Reticle problems back to its maintainers
100
118
  alwaysApply: true
101
119
  ---
102
120
 
@@ -29,6 +29,19 @@ export declare const TOKEN_VAR = "REACT_APP_RETICLE_TOKEN";
29
29
  * withReticle); CRA has no such hook without ejecting, so this message is the fix.
30
30
  */
31
31
  export declare const CRA_TOKEN_MISSING_NOTE: string;
32
+ /**
33
+ * Said as its own NOTICE line, beside the write — not inside it.
34
+ *
35
+ * The caveat has always been in the write step's `detail`, and the write step renders `[✓]`. But
36
+ * `SKILL.md` tells whoever reads the report: *"If every line is `✓`, `·` or `–`, skip to Step 4 and
37
+ * validate."* So the one fact that makes this install conditional sat in the one place the reading
38
+ * protocol says to ignore — which is exactly how it was reported to us as "4 OK marks and no
39
+ * warning". The words were on screen; the reader was following instructions.
40
+ *
41
+ * It cannot simply be promoted: `run.ts` only writes steps whose status is APPLY, so demoting the
42
+ * token step to NOTICE would stop it writing the token and break the install outright.
43
+ */
44
+ export declare const CRA_TOKEN_PER_MACHINE_NOTICE: string;
32
45
  /** Add the import after the last existing one, or null when it is already present. */
33
46
  export declare function craImportPatch(source: string): string | null;
34
47
  /** Set the token variable, or null when nothing needs to change. */
package/dist/init/cra.js CHANGED
@@ -31,6 +31,22 @@ export const TOKEN_VAR = 'REACT_APP_RETICLE_TOKEN';
31
31
  export const CRA_TOKEN_MISSING_NOTE = `${TOKEN_VAR} is not set, so Reticle cannot pair with the daemon. ` +
32
32
  `The pairing token is per-machine and ${CRA_ENV_PATH} is gitignored by CRA's template, ` +
33
33
  'so it does not survive a clone. Run `npx reticle init` in this project to write it for this machine.';
34
+ /**
35
+ * Said as its own NOTICE line, beside the write — not inside it.
36
+ *
37
+ * The caveat has always been in the write step's `detail`, and the write step renders `[✓]`. But
38
+ * `SKILL.md` tells whoever reads the report: *"If every line is `✓`, `·` or `–`, skip to Step 4 and
39
+ * validate."* So the one fact that makes this install conditional sat in the one place the reading
40
+ * protocol says to ignore — which is exactly how it was reported to us as "4 OK marks and no
41
+ * warning". The words were on screen; the reader was following instructions.
42
+ *
43
+ * It cannot simply be promoted: `run.ts` only writes steps whose status is APPLY, so demoting the
44
+ * token step to NOTICE would stop it writing the token and break the install outright.
45
+ */
46
+ export const CRA_TOKEN_PER_MACHINE_NOTICE = `${CRA_ENV_PATH} is in CRA's own .gitignore, so the token just written does not travel. ` +
47
+ 'It works on THIS machine and nowhere else: a teammate cloning the repo, CI, or a container ' +
48
+ 'gets an app that boots, never pairs, and reports it only in the browser console. ' +
49
+ 'Each developer runs `npx reticle init` once locally.';
34
50
  /** Add the import after the last existing one, or null when it is already present. */
35
51
  export function craImportPatch(source) {
36
52
  if (source.includes(CRA_DEV_MODULE_IMPORT))
@@ -62,6 +62,12 @@ export interface Detection {
62
62
  }
63
63
  /** Extract the leading major version from a semver range like "^19.0.0" or "19.1.1". */
64
64
  export declare function parseMajor(range: string | undefined): number | undefined;
65
+ /**
66
+ * Whether an installed tree identifies the manager that built it. Callers use this to decide whether
67
+ * they still need weaker evidence — see `resolveLockfiles`, which stops inheriting an ancestor
68
+ * lockfile once the project's own tree can answer the question.
69
+ */
70
+ export declare function namesAPackageManager(markers: ReadonlySet<string>): boolean;
65
71
  export declare function detectPackageManager(lockfiles: ReadonlySet<string>, nodeModulesMarkers: ReadonlySet<string>): PackageManager;
66
72
  export declare function detect(input: DetectInput): Detection;
67
73
  interface InstallCommand {
@@ -75,6 +75,14 @@ function packageManagerFromNodeModules(markers) {
75
75
  return pm;
76
76
  return undefined;
77
77
  }
78
+ /**
79
+ * Whether an installed tree identifies the manager that built it. Callers use this to decide whether
80
+ * they still need weaker evidence — see `resolveLockfiles`, which stops inheriting an ancestor
81
+ * lockfile once the project's own tree can answer the question.
82
+ */
83
+ export function namesAPackageManager(markers) {
84
+ return packageManagerFromNodeModules(markers) !== undefined;
85
+ }
78
86
  export function detectPackageManager(lockfiles, nodeModulesMarkers) {
79
87
  if (lockfiles.has('pnpm-lock.yaml'))
80
88
  return PackageManager.PNPM;
@@ -0,0 +1,81 @@
1
+ export declare const McpClient: {
2
+ readonly CLAUDE_CODE: "claude-code";
3
+ readonly CURSOR: "cursor";
4
+ readonly WINDSURF: "windsurf";
5
+ readonly OPENCODE: "opencode";
6
+ readonly CODEX: "codex";
7
+ readonly GEMINI: "gemini";
8
+ readonly VSCODE: "vscode";
9
+ };
10
+ export type McpClient = (typeof McpClient)[keyof typeof McpClient];
11
+ /** Where the config lives. `cli` means the client owns registration and we shell out to it. */
12
+ export declare const ConfigScope: {
13
+ readonly HOME: "home";
14
+ readonly PROJECT: "project";
15
+ readonly CLI: "cli";
16
+ };
17
+ export type ConfigScope = (typeof ConfigScope)[keyof typeof ConfigScope];
18
+ /** How the file is written. `toml` is deliberately never auto-merged — see mergeClientConfig. */
19
+ export declare const ConfigFormat: {
20
+ readonly JSON: "json";
21
+ readonly TOML: "toml";
22
+ readonly CLI: "cli";
23
+ };
24
+ export type ConfigFormat = (typeof ConfigFormat)[keyof typeof ConfigFormat];
25
+ export interface ClientSpec {
26
+ id: McpClient;
27
+ label: string;
28
+ scope: ConfigScope;
29
+ /** Relative to `$HOME` for `home`, to the project root for `project`. Empty for `cli`. */
30
+ relPath: string;
31
+ format: ConfigFormat;
32
+ /** The object key holding the server map — `mcpServers`, `servers`, or `mcp`. */
33
+ serversKey: string;
34
+ /** Our entry, in THIS client's shape. */
35
+ entry: () => unknown;
36
+ /** Where the shape came from, so the next person can re-check it rather than trust this file. */
37
+ docs: string;
38
+ }
39
+ export declare const MCP_CLIENTS: readonly ClientSpec[];
40
+ export declare function clientSpec(id: McpClient): ClientSpec;
41
+ export declare const ClientMergeStatus: {
42
+ readonly APPLY: "apply";
43
+ readonly ALREADY: "already";
44
+ readonly MANUAL: "manual";
45
+ };
46
+ export type ClientMergeStatus = (typeof ClientMergeStatus)[keyof typeof ClientMergeStatus];
47
+ export interface ClientMergeResult {
48
+ status: ClientMergeStatus;
49
+ /** Full file content to write. Byte-identical to `existing` when not `apply`. */
50
+ content: string;
51
+ }
52
+ /**
53
+ * Merge our server into this client's config.
54
+ *
55
+ * TOML is never auto-merged. Editing TOML without a parser is how a config file gets corrupted, and
56
+ * a corrupted `~/.codex/config.toml` costs the user every server they had, not just ours. Printing
57
+ * the exact block is honest; writing a guess is not — so Codex returns MANUAL with a snippet.
58
+ */
59
+ export declare function mergeClientConfig(spec: ClientSpec, existing: string | null): ClientMergeResult;
60
+ /**
61
+ * A block the user can paste, for a client we could not write.
62
+ *
63
+ * Every client gets one, including the ones we CAN write: when a merge comes back MANUAL because the
64
+ * file could not be parsed, the snippet is the only actionable thing left to give.
65
+ */
66
+ export declare function clientSnippet(spec: ClientSpec): string;
67
+ /**
68
+ * The path whose existence means "this user has this client".
69
+ *
70
+ * Detection is deliberately conservative and one-directional: we write into a config a client
71
+ * ALREADY has, and never create `~/.gemini/` or `~/.codeium/` for somebody who does not use them.
72
+ * Littering a home directory to register a server nobody asked for is worse than printing a snippet.
73
+ *
74
+ * For a `home` client this is the directory above its config; for a `project` client it is the
75
+ * config file (or its directory) inside the repo, which is unambiguous evidence somebody uses it
76
+ * here. Same reasoning as Cursor's existing rule, which also accepts a project-level `.cursor/`
77
+ * because a fresh Cursor profile has not written `~/.cursor` yet.
78
+ */
79
+ export declare function clientMarkerRelPath(spec: ClientSpec): string;
80
+ /** Clients `init` can wire by writing a file — everything but the CLI-registered one. */
81
+ export declare function fileBackedClients(): readonly ClientSpec[];
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Where each agent keeps its MCP config, and how to write ours into it.
3
+ *
4
+ * `init` knew two clients: Claude Code (a CLI shell-out) and Cursor (a JSON merge). Everything else
5
+ * got a printed snippet. That is the blocker under the whole release matrix — a per-client release
6
+ * artifact for a client `init` cannot wire measures whether somebody pasted JSON correctly, not
7
+ * whether the product works.
8
+ *
9
+ * Every path and shape below was read from the client's own documentation rather than recalled,
10
+ * because they differ in ways that look like they should not:
11
+ *
12
+ * Cursor / Windsurf / Gemini `mcpServers: { name: { command, args } }` — one shape, three paths
13
+ * VS Code `servers:` — a different key entirely
14
+ * OpenCode `mcp: { name: { type, command: [cmd, ...args] } }` — an ARRAY
15
+ * Codex TOML
16
+ *
17
+ * A single "write mcp.json" assuming the Cursor shape would produce a file three of these ignore:
18
+ * an install that reports success and registers nothing, which is the exact failure class this
19
+ * repo's install gate exists to catch.
20
+ */
21
+ import { NPX, MCP_SERVER_NAME, npxServerArgs } from './mcp.js';
22
+ import { RETICLE_NPM_PACKAGE } from '../version/server-version.js';
23
+ export const McpClient = {
24
+ CLAUDE_CODE: 'claude-code',
25
+ CURSOR: 'cursor',
26
+ WINDSURF: 'windsurf',
27
+ OPENCODE: 'opencode',
28
+ CODEX: 'codex',
29
+ GEMINI: 'gemini',
30
+ VSCODE: 'vscode',
31
+ };
32
+ /** Where the config lives. `cli` means the client owns registration and we shell out to it. */
33
+ export const ConfigScope = {
34
+ HOME: 'home',
35
+ PROJECT: 'project',
36
+ CLI: 'cli',
37
+ };
38
+ /** How the file is written. `toml` is deliberately never auto-merged — see mergeClientConfig. */
39
+ export const ConfigFormat = {
40
+ JSON: 'json',
41
+ TOML: 'toml',
42
+ CLI: 'cli',
43
+ };
44
+ /** The `{ command, args }` entry Cursor, Windsurf, Gemini and VS Code all take. */
45
+ function commandArgsEntry() {
46
+ return { command: NPX, args: npxServerArgs() };
47
+ }
48
+ /**
49
+ * OpenCode's entry. `command` is a single ARRAY containing the executable and its arguments, and
50
+ * `enabled` is explicit — a server registered but disabled is the quietest possible failure.
51
+ */
52
+ function openCodeEntry() {
53
+ return { type: 'local', command: [NPX, ...npxServerArgs()], enabled: true };
54
+ }
55
+ export const MCP_CLIENTS = [
56
+ {
57
+ id: McpClient.CLAUDE_CODE,
58
+ label: 'Claude Code',
59
+ scope: ConfigScope.CLI,
60
+ relPath: '',
61
+ format: ConfigFormat.CLI,
62
+ serversKey: 'mcpServers',
63
+ entry: commandArgsEntry,
64
+ docs: 'registered via `claude mcp add -s user` — see init/mcp.ts',
65
+ },
66
+ {
67
+ id: McpClient.CURSOR,
68
+ label: 'Cursor',
69
+ scope: ConfigScope.HOME,
70
+ relPath: '.cursor/mcp.json',
71
+ format: ConfigFormat.JSON,
72
+ serversKey: 'mcpServers',
73
+ entry: commandArgsEntry,
74
+ docs: 'https://docs.cursor.com — global ~/.cursor/mcp.json, or ./.cursor/mcp.json per project',
75
+ },
76
+ {
77
+ id: McpClient.WINDSURF,
78
+ label: 'Windsurf',
79
+ scope: ConfigScope.HOME,
80
+ relPath: '.codeium/windsurf/mcp_config.json',
81
+ format: ConfigFormat.JSON,
82
+ serversKey: 'mcpServers',
83
+ entry: commandArgsEntry,
84
+ docs: 'Windsurf docs — ~/.codeium/windsurf/mcp_config.json (same shape as Cursor, different path)',
85
+ },
86
+ {
87
+ id: McpClient.GEMINI,
88
+ label: 'Gemini CLI',
89
+ scope: ConfigScope.HOME,
90
+ relPath: '.gemini/settings.json',
91
+ format: ConfigFormat.JSON,
92
+ serversKey: 'mcpServers',
93
+ entry: commandArgsEntry,
94
+ docs: 'https://google-gemini.github.io/gemini-cli/docs/tools/mcp-server.html',
95
+ },
96
+ {
97
+ id: McpClient.VSCODE,
98
+ label: 'VS Code (Copilot)',
99
+ scope: ConfigScope.PROJECT,
100
+ relPath: '.vscode/mcp.json',
101
+ format: ConfigFormat.JSON,
102
+ // NOT `mcpServers`. Writing that key here registers nothing and reports success.
103
+ serversKey: 'servers',
104
+ entry: commandArgsEntry,
105
+ docs: 'VS Code MCP docs — .vscode/mcp.json, key is `servers`',
106
+ },
107
+ {
108
+ id: McpClient.OPENCODE,
109
+ label: 'OpenCode',
110
+ scope: ConfigScope.PROJECT,
111
+ relPath: 'opencode.json',
112
+ format: ConfigFormat.JSON,
113
+ serversKey: 'mcp',
114
+ entry: openCodeEntry,
115
+ docs: 'https://opencode.ai/docs/config — `mcp` key, type:"local", command is an array',
116
+ },
117
+ {
118
+ id: McpClient.CODEX,
119
+ label: 'Codex CLI',
120
+ scope: ConfigScope.HOME,
121
+ relPath: '.codex/config.toml',
122
+ format: ConfigFormat.TOML,
123
+ serversKey: 'mcp_servers',
124
+ entry: commandArgsEntry,
125
+ docs: 'https://developers.openai.com/codex/mcp — ~/.codex/config.toml, [mcp_servers.<name>]',
126
+ },
127
+ ];
128
+ export function clientSpec(id) {
129
+ const found = MCP_CLIENTS.find((spec) => spec.id === id);
130
+ if (found === undefined)
131
+ throw new Error(`no MCP client spec for '${id}'`);
132
+ return found;
133
+ }
134
+ export const ClientMergeStatus = {
135
+ APPLY: 'apply',
136
+ ALREADY: 'already',
137
+ MANUAL: 'manual',
138
+ };
139
+ function parseConfig(existing) {
140
+ if (null === existing || 0 === existing.trim().length)
141
+ return { ok: true, config: {} };
142
+ try {
143
+ const parsed = JSON.parse(existing);
144
+ // Valid JSON that is not a PLAIN OBJECT — `[]`, `3`, `"x"`, `null` — must not fall through to an
145
+ // empty config, because the file then gets rewritten wholesale. Unparseable JSON was always
146
+ // handled conservatively; this adjacent case destroyed the file instead.
147
+ if (typeof parsed !== 'object' || null === parsed || Array.isArray(parsed))
148
+ return { ok: false };
149
+ return { ok: true, config: parsed };
150
+ }
151
+ catch {
152
+ return { ok: false };
153
+ }
154
+ }
155
+ /**
156
+ * Should this entry be left exactly as it is?
157
+ *
158
+ * Two different reasons to leave one alone, and only one is "it is correct":
159
+ * - it already matches what we would write; or
160
+ * - it is not an entry WE wrote. Somebody pointing `reticle` at their own local build made a
161
+ * deliberate choice, and replacing it with the published npx command breaks a setup to fix a
162
+ * problem they do not have.
163
+ *
164
+ * What IS repaired is a stale entry of our own shape — an old pin, a moved command — which
165
+ * presence-only idempotency reported as "already registered" and never fixed, so an upgrade could
166
+ * not repair the thing an upgrade exists to repair.
167
+ */
168
+ function leaveEntryAlone(existing, spec) {
169
+ if (JSON.stringify(existing) === JSON.stringify(spec.entry()))
170
+ return true;
171
+ if (typeof existing !== 'object' || null === existing)
172
+ return true;
173
+ const record = existing;
174
+ // Both shapes carry the package name somewhere in a string; find it without assuming which field.
175
+ const tokens = [record.command, record.args].flat().filter((v) => 'string' === typeof v);
176
+ const ours = tokens.some((t) => t === NPX) && tokens.some((t) => t.includes(RETICLE_NPM_PACKAGE));
177
+ return !ours;
178
+ }
179
+ /**
180
+ * Merge our server into this client's config.
181
+ *
182
+ * TOML is never auto-merged. Editing TOML without a parser is how a config file gets corrupted, and
183
+ * a corrupted `~/.codex/config.toml` costs the user every server they had, not just ours. Printing
184
+ * the exact block is honest; writing a guess is not — so Codex returns MANUAL with a snippet.
185
+ */
186
+ export function mergeClientConfig(spec, existing) {
187
+ if (spec.format !== ConfigFormat.JSON) {
188
+ return { status: ClientMergeStatus.MANUAL, content: existing ?? '' };
189
+ }
190
+ const parsed = parseConfig(existing);
191
+ if (!parsed.ok)
192
+ return { status: ClientMergeStatus.MANUAL, content: existing ?? '' };
193
+ const config = parsed.config;
194
+ const current = config[spec.serversKey];
195
+ const servers = 'object' === typeof current && null !== current && !Array.isArray(current)
196
+ ? current
197
+ : {};
198
+ if (Object.prototype.hasOwnProperty.call(servers, MCP_SERVER_NAME) &&
199
+ leaveEntryAlone(servers[MCP_SERVER_NAME], spec)) {
200
+ return { status: ClientMergeStatus.ALREADY, content: existing ?? '' };
201
+ }
202
+ // Only OUR key is replaced; every other server and top-level key is carried through untouched.
203
+ const merged = {
204
+ ...config,
205
+ [spec.serversKey]: { ...servers, [MCP_SERVER_NAME]: spec.entry() },
206
+ };
207
+ return { status: ClientMergeStatus.APPLY, content: `${JSON.stringify(merged, null, 2)}\n` };
208
+ }
209
+ /**
210
+ * A block the user can paste, for a client we could not write.
211
+ *
212
+ * Every client gets one, including the ones we CAN write: when a merge comes back MANUAL because the
213
+ * file could not be parsed, the snippet is the only actionable thing left to give.
214
+ */
215
+ export function clientSnippet(spec) {
216
+ if (spec.format === ConfigFormat.TOML) {
217
+ const args = npxServerArgs()
218
+ .map((a) => `"${a}"`)
219
+ .join(', ');
220
+ return `[mcp_servers.${MCP_SERVER_NAME}]\ncommand = "${NPX}"\nargs = [${args}]\n`;
221
+ }
222
+ if (spec.format === ConfigFormat.CLI) {
223
+ return `claude mcp add -s user ${MCP_SERVER_NAME} -- ${NPX} ${npxServerArgs().join(' ')}\n`;
224
+ }
225
+ return `${JSON.stringify({ [spec.serversKey]: { [MCP_SERVER_NAME]: spec.entry() } }, null, 2)}\n`;
226
+ }
227
+ /**
228
+ * The path whose existence means "this user has this client".
229
+ *
230
+ * Detection is deliberately conservative and one-directional: we write into a config a client
231
+ * ALREADY has, and never create `~/.gemini/` or `~/.codeium/` for somebody who does not use them.
232
+ * Littering a home directory to register a server nobody asked for is worse than printing a snippet.
233
+ *
234
+ * For a `home` client this is the directory above its config; for a `project` client it is the
235
+ * config file (or its directory) inside the repo, which is unambiguous evidence somebody uses it
236
+ * here. Same reasoning as Cursor's existing rule, which also accepts a project-level `.cursor/`
237
+ * because a fresh Cursor profile has not written `~/.cursor` yet.
238
+ */
239
+ export function clientMarkerRelPath(spec) {
240
+ if (spec.scope === ConfigScope.CLI)
241
+ return '';
242
+ const parts = spec.relPath.split('/');
243
+ return parts.length > 1 ? parts.slice(0, -1).join('/') : spec.relPath;
244
+ }
245
+ /** Clients `init` can wire by writing a file — everything but the CLI-registered one. */
246
+ export function fileBackedClients() {
247
+ return MCP_CLIENTS.filter((spec) => spec.scope !== ConfigScope.CLI);
248
+ }
@@ -8,6 +8,7 @@ import { NodePlatform } from '../platform.js';
8
8
  import { join, dirname, isAbsolute } from 'node:path';
9
9
  import { homedir } from 'node:os';
10
10
  import { spawnSync } from 'node:child_process';
11
+ import { windowsShellArg } from './windows-quote.js';
11
12
  /**
12
13
  * `shell: true` ONLY where it earns its keep.
13
14
  *
@@ -19,11 +20,16 @@ import { spawnSync } from 'node:child_process';
19
20
  function shellOpt() {
20
21
  return NodePlatform.WINDOWS === process.platform ? { shell: true } : {};
21
22
  }
22
- /** Under a shell, quote what the shell would otherwise split. Windows only, where shell is required. */
23
+ /**
24
+ * Under a shell, quote what the shell would otherwise split or interpret. Windows only, where
25
+ * `shell: true` is required (see shellOpt). The rule itself lives in windows-quote.ts as a pure
26
+ * function so it can be tested on every platform — keeping it inside this branch is why it was
27
+ * wrong for as long as it was.
28
+ */
23
29
  function shellSafe(args) {
24
30
  if (process.platform !== NodePlatform.WINDOWS)
25
31
  return [...args];
26
- return args.map((arg) => (/[\s"]/.test(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg));
32
+ return args.map(windowsShellArg);
27
33
  }
28
34
  export function buildNodeIo(cwd) {
29
35
  // Project-relative by default; absolute paths (e.g. ~/.cursor/mcp.json) pass through unchanged.
@@ -6,7 +6,7 @@
6
6
  import { patchViteConfig, VitePatchKind } from './vite-config.js';
7
7
  import { patchNextConfig, patchRootLayout, patchPagesApp } from './next-patch.js';
8
8
  import { patchAstroConfig, patchAstroLayout } from './astro-patch.js';
9
- import { CRA_DEV_MODULE_IMPORT, CRA_DEV_MODULE_PATH, CRA_ENV_PATH, TOKEN_VAR, craDevModuleFile, craEnvPatch, craImportPatch, } from './cra.js';
9
+ import { CRA_DEV_MODULE_IMPORT, CRA_DEV_MODULE_PATH, CRA_ENV_PATH, CRA_TOKEN_PER_MACHINE_NOTICE, TOKEN_VAR, craDevModuleFile, craEnvPatch, craImportPatch, } from './cra.js';
10
10
  import { PatchKind } from './patch-kind.js';
11
11
  import { viteManual, NEXT_LAYOUT_MANUAL, NEXT_LAYOUT_PATH, viteDevModuleFile, VITE_DEV_MODULE_PATH, nextReticleDevFile, NEXT_RETICLE_DEV_PATH, nextConfigManual, svelteKitHooksFile, SVELTEKIT_HOOKS_PATH, UNVERIFIED_FRAMEWORK_NOTE, astroManual, } from './snippets.js';
12
12
  import { StepStatus } from './plan.js';
@@ -46,6 +46,7 @@ function capabilitiesStep(input) {
46
46
  const found = testids.length > 0
47
47
  ? `${String(testids.length)} data-testid values`
48
48
  : 'no data-testid values yet';
49
+ const nothingToRegister = 0 === testids.length && 0 === stores.length;
49
50
  return [
50
51
  {
51
52
  title: CAPABILITIES_TITLE,
@@ -55,6 +56,28 @@ function capabilitiesStep(input) {
55
56
  write: { path: VITE_DEV_MODULE_PATH, content: viteDevModuleFile(testids, stores) },
56
57
  dependsOnInstall: true,
57
58
  },
59
+ // The write is real; what it registers is not. `registerCapabilities({ testids: [], signals: [],
60
+ // stores: [] })` registers nothing, so `hasCapabilities` stays false — correctly — and the ✓
61
+ // above reads as if the feature is on. Reported as exactly that confusion: "hasCapabilities false
62
+ // on every session while init said ✓ Capabilities + store".
63
+ //
64
+ // Beside the write rather than replacing it, for two reasons: only APPLY steps are written
65
+ // (run.ts), and SKILL.md tells the reader to skip ✓ lines — so a caveat carried on the ✓ is a
66
+ // caveat nobody reads.
67
+ ...(nothingToRegister
68
+ ? [
69
+ {
70
+ title: 'Capabilities are empty until you edit that file',
71
+ target: VITE_DEV_MODULE_PATH,
72
+ status: StepStatus.NOTICE,
73
+ detail: 'the module was written but registers nothing — no data-testid values were found and ' +
74
+ 'no state library was detected, so `hasCapabilities` stays false and reticle_state ' +
75
+ 'has nothing to read. Add data-testid to the elements your main flow touches, or ' +
76
+ 'register a store in that file, and the agent gains what the app BELIEVES rather than ' +
77
+ 'only what it rendered.',
78
+ },
79
+ ]
80
+ : []),
58
81
  ];
59
82
  }
60
83
  export function viteSteps(input, detail = VITE_PLUGIN_DETAIL.VITE) {
@@ -219,6 +242,14 @@ export function craSteps(input) {
219
242
  detail: `set ${TOKEN_VAR} (the only channel CRA inlines) — ${CRA_ENV_PATH} is gitignored, so each teammate must run \`reticle init\` on their own machine`,
220
243
  write: { path: CRA_ENV_PATH, content: env },
221
244
  });
245
+ // Beside the write, not inside it. A ✓ line is one SKILL.md tells the reader to skip, and this
246
+ // is the fact that decides whether the install works for anyone but the person running it.
247
+ steps.push({
248
+ title: 'Pairing token is per-machine',
249
+ target: CRA_ENV_PATH,
250
+ status: StepStatus.NOTICE,
251
+ detail: CRA_TOKEN_PER_MACHINE_NOTICE,
252
+ });
222
253
  }
223
254
  else if ('' === token) {
224
255
  // No daemon has ever run here, so there is no token to inline. Omitting the step entirely made
@@ -227,7 +258,10 @@ export function craSteps(input) {
227
258
  title: 'Pairing token',
228
259
  target: CRA_ENV_PATH,
229
260
  status: StepStatus.MANUAL,
230
- detail: `no pairing token yet — run \`reticle start\`, then \`reticle init\` again to write ${TOKEN_VAR}`,
261
+ // `reticle serve`, not `reticle start` — the latter is not a verb this CLI dispatches, and
262
+ // this message is read by someone whose app boots and never pairs. Handing them a command
263
+ // that errors is a second dead end on the first.
264
+ detail: `no pairing token yet — the daemon writes one on first run. Start it with \`reticle serve\` (or let your agent run \`reticle mcp\`), then \`reticle init\` again to write ${TOKEN_VAR}`,
231
265
  });
232
266
  }
233
267
  if (null === entry) {