@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.
- package/SKILL.md +47 -9
- package/dist/dev/stale-issue-guard.d.ts +43 -0
- package/dist/dev/stale-issue-guard.js +75 -0
- package/dist/domain/domain-tools.js +3 -2
- package/dist/events/predicate-asks.d.ts +17 -0
- package/dist/events/predicate-asks.js +26 -0
- package/dist/events/predicate-eval.js +211 -24
- package/dist/events/predicate-parse.js +9 -3
- package/dist/events/predicate-schema.d.ts +18 -0
- package/dist/events/predicate-schema.js +55 -0
- package/dist/flows/flow-tools.js +33 -4
- package/dist/flows/predicate-to-expect.js +8 -0
- package/dist/flows/verify-change-tools.js +5 -1
- package/dist/honesty/blind-spots.d.ts +9 -0
- package/dist/honesty/blind-spots.js +17 -4
- package/dist/honesty/gap-ledger.d.ts +52 -0
- package/dist/honesty/gap-ledger.js +63 -0
- package/dist/honesty/instrumentation-gaps.d.ts +55 -0
- package/dist/honesty/instrumentation-gaps.js +21 -0
- package/dist/honesty/observability.d.ts +37 -0
- package/dist/honesty/observability.js +40 -0
- package/dist/index.d.ts +0 -1
- package/dist/index.js +41 -0
- package/dist/init/node-io.js +3 -0
- package/dist/init/run.d.ts +18 -0
- package/dist/init/run.js +34 -3
- package/dist/init/slash-command.d.ts +1 -1
- package/dist/init/slash-command.js +33 -10
- package/dist/journal/envelope.d.ts +24 -24
- package/dist/license/license.d.ts +8 -2
- package/dist/license/license.js +10 -15
- package/dist/project/artifact-root.d.ts +83 -0
- package/dist/project/artifact-root.js +112 -0
- package/dist/project/memory-fs.d.ts +9 -0
- package/dist/project/memory-fs.js +88 -0
- package/dist/project/project-store.d.ts +17 -0
- package/dist/project/project-store.js +33 -0
- package/dist/project/remember-project.d.ts +24 -0
- package/dist/project/remember-project.js +40 -0
- package/dist/project/session-root.d.ts +15 -0
- package/dist/project/session-root.js +31 -0
- package/dist/runs/run-tools.js +2 -1
- package/dist/session/lease-availability.d.ts +40 -0
- package/dist/session/lease-availability.js +43 -0
- package/dist/session/no-session-diagnosis.d.ts +10 -0
- package/dist/session/no-session-diagnosis.js +19 -7
- package/dist/session/no-session-watch.js +1 -1
- package/dist/session/session.d.ts +9 -0
- package/dist/session/session.js +9 -0
- package/dist/session/stall-clock.d.ts +18 -0
- package/dist/session/stall-clock.js +48 -0
- package/dist/telemetry/daemon-telemetry.js +12 -23
- package/dist/telemetry/session-metrics.d.ts +1 -1
- package/dist/telemetry/session-metrics.js +1 -1
- package/dist/telemetry/telemetry.d.ts +12 -3
- package/dist/telemetry/telemetry.js +25 -5
- package/dist/tools/act-tools.js +34 -4
- package/dist/tools/assert-verdict.d.ts +2 -0
- package/dist/tools/assert-verdict.js +24 -1
- package/dist/tools/contract-tools.js +5 -3
- package/dist/tools/coverage-tools.js +33 -0
- package/dist/tools/observe-tools.js +8 -2
- package/dist/tools/tool-kit.d.ts +16 -1
- package/docs/README.md +1 -0
- package/docs/docs.json +2 -2
- package/docs/enterprise.md +3 -1
- package/docs/license-activation.mdx +145 -0
- package/docs/telemetry-contract.md +1 -12
- package/docs/telemetry-events.mdx +1 -34
- package/docs/telemetry.md +0 -1
- package/package.json +2 -2
- package/dist/telemetry/instrumentation-stall.d.ts +0 -57
- 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,
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
package/dist/license/license.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|