@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
@@ -13,8 +13,7 @@ import { profileProject } from './project-profile.js';
13
13
  import { startUpdateCheck } from '../update/update-nudge.js';
14
14
  import { markDaemonStart } from './mcp-connection.js';
15
15
  import { markInstrumentationClock } from './app-instrumented.js';
16
- import { markStallClock, reportInstrumentationStall, STALL_AFTER_MS, } from './instrumentation-stall.js';
17
- import { readProjectId } from '../cli/cli-port.js';
16
+ import { markStallClock, STALL_AFTER_MS, stallUptime } from '../session/stall-clock.js';
18
17
  /** Let the daemon finish coming up before walking the source tree for a profile. */
19
18
  const PROJECT_PROFILE_DELAY_MS = 5_000;
20
19
  /**
@@ -74,19 +73,18 @@ export function installDaemonTelemetry(cwd, now = () => Date.now()) {
74
73
  /* a profile is a nice-to-have; it must never touch the daemon */
75
74
  }
76
75
  }, PROJECT_PROFILE_DELAY_MS).unref();
76
+ // Once per daemon run: a warning repeated every ten minutes at somebody already stuck is noise.
77
+ let stallWarned = false;
77
78
  const flushWindow = () => {
78
- // BEFORE the idle return, and that ordering is the whole point: a stalled install produces an
79
- // empty window by definition — no app, so no tool calls, so nothing to roll up. Checking it
80
- // after the early return would make the one case this event exists for the one case it never
81
- // sees. It is self-limiting (once per run, never after an app connects), so running it on every
82
- // tick costs a comparison.
83
- const stalled = reportInstrumentationStall({
84
- // Same test `app_instrumented` uses for the same field, so the success and failure events
85
- // stay directly comparable: a stamped projectId means `init` has run here.
86
- initialized: readProjectId(cwd) !== undefined,
87
- agentAttached: metrics.agentEverAttached,
88
- }, now);
89
- if (stalled) {
79
+ // BEFORE the idle return, and that ordering still matters for the reason the removed event
80
+ // documented: a stalled install produces an empty window by definition — no app, so no tool
81
+ // calls, so nothing to roll up — so checking after the early return would never fire on the one
82
+ // case worth warning about. What is left here is the WARNING, for a human watching the log; the
83
+ // metric that used to ride alongside it was removed as a second, less complete way to compute
84
+ // `daemon_started` minus `app_instrumented`.
85
+ const waited = stallUptime(now());
86
+ if (!stallWarned && waited !== undefined && waited >= STALL_AFTER_MS) {
87
+ stallWarned = true;
90
88
  const minutes = Math.round(STALL_AFTER_MS / 60_000);
91
89
  process.stderr.write(`[reticle] no app has connected after ${String(minutes)} minutes — ` +
92
90
  'the install may be incomplete. Restart the dev server if you added the Reticle plugin.\n');
@@ -113,15 +111,6 @@ export function installDaemonTelemetry(cwd, now = () => Date.now()) {
113
111
  stopped ??= (async () => {
114
112
  clearInterval(flush);
115
113
  clearTimeout(firstFlush);
116
- // Last chance to say that no app ever arrived. The periodic path cannot report an
117
- // UNATTENDED stall at all — that daemon idle-exits at five minutes and the periodic
118
- // threshold is ten — so without this the event only ever describes daemons with an agent
119
- // attached, which is not the population it was built to measure. Self-limiting: if the
120
- // interval already reported, this returns false and emits nothing.
121
- reportInstrumentationStall({
122
- initialized: readProjectId(cwd) !== undefined,
123
- agentAttached: metrics.agentEverAttached,
124
- }, now, { atShutdown: true });
125
114
  // The rich one: the whole session in a single event — duration, the tool histogram, error
126
115
  // shapes, verifications, browser launches. This is what replaced the per-tool-call event.
127
116
  await getTelemetry().emit(TelemetryEventKind.DAEMON_STOPPED, {
@@ -133,7 +133,7 @@ export declare class SessionMetrics {
133
133
  /**
134
134
  * Has an agent ever attached to this daemon?
135
135
  *
136
- * Read by the instrumentation-stall check, which needs to tell two very different silences apart:
136
+ * Read by the stall clock, which needs to tell two very different silences apart:
137
137
  * a daemon nobody is using, and a daemon with an agent sitting on it that has no app to drive.
138
138
  * The second is the funnel's failure case and the first is not a problem at all.
139
139
  */
@@ -575,7 +575,7 @@ export class SessionMetrics {
575
575
  /**
576
576
  * Has an agent ever attached to this daemon?
577
577
  *
578
- * Read by the instrumentation-stall check, which needs to tell two very different silences apart:
578
+ * Read by the stall clock, which needs to tell two very different silences apart:
579
579
  * a daemon nobody is using, and a daemon with an agent sitting on it that has no app to drive.
580
580
  * The second is the funnel's failure case and the first is not a problem at all.
581
581
  */
@@ -1,4 +1,15 @@
1
- import { TelemetryEventKind, ProjectIdSource, type Crash, type Feedback, type Identity, type BugFound, type InitOutcome, type InstallSource, type McpConnection, type McpOutage, type ToolRefusal, type AppInstrumentation, type InstrumentationStall, type ProjectProfile, type SessionSummary, type TelemetryActor, type Verification, type VersionChange } from '@reticlehq/core';
1
+ import { TelemetryEventKind, ProjectIdSource, type Crash, type Feedback, type Identity, type BugFound, type InitOutcome, type InstallSource, type McpConnection, type McpOutage, type ToolRefusal, type AppInstrumentation, type ProjectProfile, type SessionSummary, type TelemetryActor, type Verification, type VersionChange } from '@reticlehq/core';
2
+ /**
3
+ * PostHog's group type for a licensed customer. Sent as `$groups` so PostHog can aggregate a whole
4
+ * organisation natively: per-company retention, "which orgs went quiet", one row per customer rather
5
+ * than one per machine.
6
+ *
7
+ * The KEY is the licence id and nothing else. The organisation's NAME is never sent from a customer's
8
+ * machine, here or anywhere; the name reaches PostHog only if we push it ourselves from the issuance
9
+ * ledger, which is the one place that mapping lives. So an event still carries no identity, and the
10
+ * analytics side learns a customer list only when we deliberately upload one.
11
+ */
12
+ export declare const POSTHOG_GROUP_ORGANIZATION = "organization";
2
13
  /**
3
14
  * Feedback gets its own, much larger budget — and a retry.
4
15
  *
@@ -78,8 +89,6 @@ export interface TelemetryExtra {
78
89
  outage?: McpOutage;
79
90
  /** `app_instrumented`: an app carrying the SDK reached this daemon for the first time. */
80
91
  instrumentation?: AppInstrumentation;
81
- /** `instrumentation_stalled`: the daemon waited and no app ever arrived. */
82
- stall?: InstrumentationStall;
83
92
  /** `reticle_installed` / `init_completed`: which published route brought this install in. */
84
93
  installSource?: InstallSource;
85
94
  }
@@ -42,6 +42,17 @@ const POSTHOG_KEY = 'phc_q4yCxKMXHfWQ43VAz4HxJXZFLqFi4nBbg5v3avemggYo';
42
42
  * still key on distinct_id.
43
43
  */
44
44
  const POSTHOG_PERSONLESS = { $process_person_profile: false };
45
+ /**
46
+ * PostHog's group type for a licensed customer. Sent as `$groups` so PostHog can aggregate a whole
47
+ * organisation natively: per-company retention, "which orgs went quiet", one row per customer rather
48
+ * than one per machine.
49
+ *
50
+ * The KEY is the licence id and nothing else. The organisation's NAME is never sent from a customer's
51
+ * machine, here or anywhere; the name reaches PostHog only if we push it ourselves from the issuance
52
+ * ledger, which is the one place that mapping lives. So an event still carries no identity, and the
53
+ * analytics side learns a customer list only when we deliberately upload one.
54
+ */
55
+ export const POSTHOG_GROUP_ORGANIZATION = 'organization';
45
56
  const SEND_TIMEOUT_MS = 2000;
46
57
  /**
47
58
  * Feedback gets its own, much larger budget — and a retry.
@@ -337,7 +348,6 @@ export const createTelemetry = (opts) => {
337
348
  ...(extra?.refusal !== undefined ? { refusal: extra.refusal } : {}),
338
349
  ...(extra?.outage !== undefined ? { outage: extra.outage } : {}),
339
350
  ...(extra?.instrumentation !== undefined ? { instrumentation: extra.instrumentation } : {}),
340
- ...(extra?.stall !== undefined ? { stall: extra.stall } : {}),
341
351
  // A SCALAR, so it needs the event build and the wire schema but no entry in `blocks` below —
342
352
  // there is nothing to flatten. It still needs both of those, which is the trap `outage` fell
343
353
  // into: a field declared on TelemetryExtra and missing from either one is dropped in silence.
@@ -348,14 +358,17 @@ export const createTelemetry = (opts) => {
348
358
  // `blocks` entry. Resolved per event off the event's own clock rather than once at startup — an
349
359
  // eleven-hour session must not report the status it booted with. All three are absent unless a
350
360
  // release has an issuer key baked, so an OSS payload is byte-identical to before.
351
- ...licenseFacts(eventTs),
361
+ // The RESOLVED env, not process.env: createTelemetry takes an env for injection, and reading
362
+ // the ambient one here would silently ignore it. Identical in production, wrong everywhere the
363
+ // seam is actually used.
364
+ ...licenseFacts(eventTs, env),
352
365
  };
353
366
  // Map the core contract onto PostHog's capture shape: id/name/time move up, the rest are properties.
354
367
  // The feedback body is FLATTENED into `feedback_*` properties rather than sent as a nested object:
355
368
  // PostHog filters and breakdowns operate on top-level properties, so a nested `feedback.stack`
356
369
  // would be invisible to the exact "which stacks report the most bugs" question this exists to
357
370
  // answer. One prefix keeps them grouped in the UI without a nested path.
358
- const { anonymousId: distinctId, event: name, ts, sessionId: eventSessionId, feedback, session, project, verification, versionChange, crash, identity, connection, init, bug, refusal, outage, instrumentation, stall, ...rest } = event;
371
+ const { anonymousId: distinctId, event: name, ts, sessionId: eventSessionId, feedback, session, project, verification, versionChange, crash, identity, connection, init, bug, refusal, outage, instrumentation, ...rest } = event;
359
372
  // `$session_id` is PostHog's OWN session property, so sending ours under that name lights up
360
373
  // its built-in session views and funnels for free rather than requiring custom HogQL everywhere.
361
374
  // `sessionId` rides only on events that happened inside a daemon run. A one-shot CLI command
@@ -386,7 +399,6 @@ export const createTelemetry = (opts) => {
386
399
  refusal,
387
400
  outage,
388
401
  instrumentation,
389
- stall,
390
402
  };
391
403
  for (const [prefix, block] of Object.entries(blocks)) {
392
404
  for (const [key, value] of Object.entries(block ?? {})) {
@@ -397,7 +409,15 @@ export const createTelemetry = (opts) => {
397
409
  event: name,
398
410
  distinct_id: distinctId,
399
411
  timestamp: new Date(ts).toISOString(),
400
- properties: { ...properties, ...POSTHOG_PERSONLESS },
412
+ properties: {
413
+ ...properties,
414
+ ...POSTHOG_PERSONLESS,
415
+ // Only on a licensed build. An unlicensed event has no organisation to belong to, and an
416
+ // empty group key would mint a phantom "no org" bucket that every OSS install falls into.
417
+ ...(event.licenseId !== undefined
418
+ ? { $groups: { [POSTHOG_GROUP_ORGANIZATION]: event.licenseId } }
419
+ : {}),
420
+ },
401
421
  };
402
422
  const body = JSON.stringify({ api_key: apiKey, batch: [capture] });
403
423
  const filePath = env[Env.FILE];
@@ -8,7 +8,7 @@ import { resolveTargetRef } from './resolve-target.js';
8
8
  import { z } from 'zod';
9
9
  import { aliasParam } from './alias-args.js';
10
10
  import { captureAct, compileSequenceStep } from '../flows/replay.js';
11
- import { ActionType, ActionWarning, CaptureLoss, DEFAULT_ASSERT_TIMEOUT_MS, InputMode, ReticleCommand, Verified, PredicateKind, } from '@reticlehq/core';
11
+ import { ActionType, ActionWarning, CaptureLoss, DEFAULT_ASSERT_TIMEOUT_MS, InputMode, ReticleCommand, Verified, VerifiedReason, PredicateKind, } from '@reticlehq/core';
12
12
  import { assertNativeInputSupported } from './act-danger.js';
13
13
  import { leanActResult, mutatedWithin } from './act-view.js';
14
14
  import { ReticleTool } from './tool-names.js';
@@ -16,6 +16,10 @@ import { buildReactionReport, summarizeReaction } from '../events/reaction.js';
16
16
  import { parsePredicate } from '../events/predicate-parse.js';
17
17
  import { causalSummary } from '../capsule/causal-summary.js';
18
18
  import { findContradictions } from '../events/contradictions.js';
19
+ import { gapsForAction } from '../honesty/instrumentation-gaps.js';
20
+ import { noteSessionGaps } from '../honesty/gap-ledger.js';
21
+ import { declaresState } from '../events/predicate-asks.js';
22
+ import { isStateUnwatched } from '../honesty/blind-spots.js';
19
23
  import { inFlightRequestLabels, repeatedRequestLabels, waitForInFlight, } from './settle-in-flight.js';
20
24
  import { waitForReaction } from './react-grace.js';
21
25
  import { decideVerified } from '../honesty/verified.js';
@@ -26,7 +30,7 @@ import { saveFailedAssertCapsule } from './act-capsule.js';
26
30
  import { buildDivergenceCapsule } from '../capsule/capsule.js';
27
31
  import { predicateToExpectedLinks } from '../capsule/predicate-to-links.js';
28
32
  import { buildHonestyBlock } from '../honesty/honesty.js';
29
- import { BlindSpotKind, buildCoverageStatement, blindSpotsFromState, transportGapNote, Coverage, impeachesCapture, } from '../honesty/blind-spots.js';
33
+ import { buildCoverageStatement, blindSpotsFromState, transportGapNote, Coverage, impeachesCapture, } from '../honesty/blind-spots.js';
30
34
  import { hasAcceptedWrite } from '../honesty/accepted-write.js';
31
35
  import { unreadWriteLabels } from '../honesty/unread-outcome.js';
32
36
  import { evaluatePredicate, waitForPredicate, provenExpectedLinks, PredicateSchema, } from '../events/predicate.js';
@@ -432,6 +436,10 @@ export const ACT_TOOLS = [
432
436
  .array(z.unknown())
433
437
  .optional()
434
438
  .describe('Channels that DISAGREE about this action — the UI advanced while its write failed, a success signal fired over a failed request, a response changed nothing, a duplicate fired, a request never settled. OMITTED when clean. Treat any entry as a finding even when the verdict is green: a passing assertion and a contradicted channel is exactly the false green this exists to catch.'),
439
+ instrumentationGaps: z
440
+ .array(z.unknown())
441
+ .optional()
442
+ .describe('What the APP did not tell Reticle, and the one change that would fix it — each entry is { kind, missing, cost, fix, source?, ref? }. Reported ONLY where an absence made THIS verdict weaker (a red that cannot name a file:line, a state assertion with no registered store, a DOM change no signal announced, a route change nothing signalled); never a survey of the page, so an entry is always work worth doing now. OMITTED when the app told Reticle everything it needed. Applying `fix` makes every later verdict on this app stronger, not just this one.'),
435
443
  honesty: z
436
444
  .unknown()
437
445
  .describe('The verdict trust block { grade, attribution, coverage, integrity, envelope? } — a green never looks stronger than this. Gate on grade ≥ net AND integrity.clean. Fields that were not measured are OMITTED rather than reported as zero, so treat an absent `envelope` as "not sampled", never as a failure.'),
@@ -561,7 +569,7 @@ export const ACT_TOOLS = [
561
569
  const coverage = buildCoverageStatement(spots);
562
570
  // Nothing subscribed ⇒ the state channel is dark, and the summary must say so rather than
563
571
  // report an empty diff list that reads like a fact about the app. See CausalSummary.
564
- const stateUnwatched = spots.some((s) => s.kind === BlindSpotKind.UNWATCHED_STATE && s.count > 0);
572
+ const stateUnwatched = isStateUnwatched(spots);
565
573
  // Only a spot that IMPEACHES the capture belongs in integrity — see impeachesCapture. A
566
574
  // structural boundary (virtualized rows, a cross-origin frame) is reported as coverage and
567
575
  // must not downgrade a verdict about what WAS observed.
@@ -672,6 +680,25 @@ export const ACT_TOOLS = [
672
680
  repeated: repeatedRequestLabels(windowEvents),
673
681
  },
674
682
  });
683
+ // Computed once: the verdict block reports it, and the instrumentation gaps are a second
684
+ // reading of the same evidence rather than a new observation.
685
+ const actionSummary = causalSummary(windowEvents, { stateUnwatched });
686
+ const gaps = gapsForAction({
687
+ pass: verdict.pass,
688
+ proved: decision.verifiedReason === VerifiedReason.PROVED,
689
+ sourceKnown: actedSource !== undefined,
690
+ ref: asString(args['ref']),
691
+ stateAsked: declaresState(until),
692
+ stateUnwatched,
693
+ domMutated: (session.lastAct.effect().mutatedWithin ?? 0) > 0,
694
+ signalsFired: actionSummary.signals.length,
695
+ routeChanged: actionSummary.route !== undefined,
696
+ routeSignalFired: actionSummary.signals.some((name) => name.startsWith('route')),
697
+ });
698
+ // Recorded on the session, so a later "am I done?" can answer with what is STILL missing
699
+ // rather than with everything that was ever missing. An empty list closes a gap, which is
700
+ // why it is noted rather than skipped.
701
+ noteSessionGaps(session, gaps);
675
702
  return withControl(session, {
676
703
  ...decision,
677
704
  effect: leanActResult(actResult.result),
@@ -686,7 +713,10 @@ export const ACT_TOOLS = [
686
713
  ...(capsuleSaved === undefined ? {} : { capsuleSaved }),
687
714
  // The window cannot say whether anything was WATCHING state — that is a level fact the
688
715
  // session holds. Without it an empty `stateDiffs` reads as "the app changed nothing".
689
- summary: causalSummary(windowEvents, { stateUnwatched }),
716
+ summary: actionSummary,
717
+ // What the APP did not tell Reticle, and the one change that would fix it. Reported only
718
+ // where an absence made this very verdict weaker — never as a survey of the page.
719
+ ...(gaps.length > 0 ? { instrumentationGaps: gaps } : {}),
690
720
  // Cross-channel disagreement, reported WITH the action that caused it.
691
721
  //
692
722
  // This is the one finding here a human structurally cannot make — they watch one channel,
@@ -1,3 +1,4 @@
1
+ import type { InstrumentationGap } from '@reticlehq/core';
1
2
  import type { Predicate } from '../events/predicate.js';
2
3
  import type { Session } from '../session/session.js';
3
4
  import { type Contradiction } from '../events/contradictions.js';
@@ -26,4 +27,5 @@ observationLost?: boolean): Promise<{
26
27
  decision: Record<string, unknown>;
27
28
  contradictions: Contradiction[];
28
29
  coverage: Record<string, unknown>;
30
+ gaps: InstrumentationGap[];
29
31
  }>;
@@ -1,4 +1,8 @@
1
1
  import { BlindSpotKind, CaptureLoss, PredicateKind } from '@reticlehq/core';
2
+ import { gapsForAction } from '../honesty/instrumentation-gaps.js';
3
+ import { noteSessionGaps } from '../honesty/gap-ledger.js';
4
+ import { declaresState } from '../events/predicate-asks.js';
5
+ import { isStateUnwatched } from '../honesty/blind-spots.js';
2
6
  import { findContradictions } from '../events/contradictions.js';
3
7
  import { declaredExpectations } from '../events/declared.js';
4
8
  import { blindSpotsFromState, buildCoverageStatement, Coverage, impeachesCapture, transportGapNote, } from '../honesty/blind-spots.js';
@@ -122,5 +126,24 @@ observationLost) {
122
126
  repeated: repeatedRequestLabels(windowEvents),
123
127
  },
124
128
  });
125
- return { decision: decision, contradictions, coverage };
129
+ // The same rule the act path uses, fed by what THIS path knows. An assertion drives nothing, so
130
+ // only two of the four gates can fire here: a red with no remembered source to point at, and a
131
+ // state assertion against an app that registers no store.
132
+ const gaps = gapsForAction({
133
+ pass,
134
+ sourceKnown: session.lastAct.source() !== undefined,
135
+ stateAsked: declaresState(predicate),
136
+ stateUnwatched: isStateUnwatched(spots),
137
+ domMutated: false,
138
+ signalsFired: 0,
139
+ routeChanged: false,
140
+ routeSignalFired: false,
141
+ });
142
+ noteSessionGaps(session, gaps);
143
+ return {
144
+ decision: decision,
145
+ contradictions,
146
+ coverage,
147
+ gaps,
148
+ };
126
149
  }
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { sessionRoot } from '../project/session-root.js';
2
3
  import { CapabilitiesSchema, ContractReadError, FROM_DISK_ARG, ReticleCommand, } from '@reticlehq/core';
3
4
  import { ReticleTool } from './tool-names.js';
4
5
  import { asString } from './tools-helpers.js';
@@ -59,7 +60,7 @@ export const CONTRACT_TOOLS = [
59
60
  },
60
61
  handler: async (deps, args) => {
61
62
  if (true === args[FROM_DISK_ARG]) {
62
- const r = await readContract(deps.fs, deps.reticleRoot);
63
+ const r = await readContract(deps.fs, sessionRoot(deps, asString(args['sessionId'])));
63
64
  if (!r.ok)
64
65
  throw new Error(r.reason === ContractReadError.MISSING
65
66
  ? 'no .reticle/contract.json on disk — run reticle_contract_save first (or omit fromDisk to read the live session)'
@@ -84,10 +85,11 @@ export const CONTRACT_TOOLS = [
84
85
  const res = await commandOrThrow(deps, asString(args['sessionId']), ReticleCommand.CAPABILITIES, {});
85
86
  const caps = CapabilitiesSchema.parse(res);
86
87
  assertSameProject(deps, asString(args['sessionId']));
87
- await writeContract(deps.fs, deps.reticleRoot, caps, deps.now);
88
+ const root = sessionRoot(deps, asString(args['sessionId']));
89
+ await writeContract(deps.fs, root, caps, deps.now);
88
90
  return {
89
91
  saved: true,
90
- path: reticleDirPaths(deps.reticleRoot).contract,
92
+ path: reticleDirPaths(root).contract,
91
93
  testidCount: caps.testids.length,
92
94
  signalCount: caps.signals.length,
93
95
  };
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { coverageRegressed, observabilityOf } from '../honesty/observability.js';
2
3
  import { ReticleCommand, SnapshotMode } from '@reticlehq/core';
3
4
  import { ReticleTool } from './tool-names.js';
4
5
  import { asString } from './tools-helpers.js';
@@ -62,6 +63,22 @@ export const COVERAGE_TOOLS = [
62
63
  .number()
63
64
  .optional()
64
65
  .describe('Controls you drove that are no longer on the page — usually because the action SUCCEEDED and removed them (archive/delete/submit/navigate). Counted separately so `exercised: 0` never appears immediately after real work.'),
66
+ instrumentationGaps: z
67
+ .array(z.unknown())
68
+ .optional()
69
+ .describe('What this app still cannot tell Reticle, as of your most recent verdict — each entry is { kind, missing, cost, fix, source?, ref? }. These are not controls you skipped; they are checks this app CANNOT answer until it is instrumented, so driving the untouched list will not close them. Apply each `fix` and re-verify: the gap disappears from this list when the app can answer, and every later verdict on this app gets stronger. OMITTED when nothing is missing.'),
70
+ unproven: z
71
+ .boolean()
72
+ .optional()
73
+ .describe('True when verification is NOT finished for a reason driving more controls cannot fix — instrumentationGaps is non-empty. Present only when true, so its absence is not a claim.'),
74
+ observability: z
75
+ .object({ driven: z.number(), observable: z.number(), percent: z.number().optional() })
76
+ .optional()
77
+ .describe('Of the controls you DROVE, how many Reticle could fully observe. `untouched` above is work left for you; this is work left in the APP, and driving more controls does not move it. `percent` is OMITTED when nothing was driven, because 0/0 is not 100%.'),
78
+ observabilityRegressed: z
79
+ .object({ was: z.number(), now: z.number() })
80
+ .optional()
81
+ .describe('This project has previously reached a HIGHER observability than this run did. Usually means an assertion or an instrumented path was removed — the cheapest way to stop a gap firing is to stop asserting the thing that revealed it. Present only when a drop is real and the run was large enough to compare.'),
65
82
  },
66
83
  handler: async (deps, args) => {
67
84
  const sessionId = asString(args['sessionId']);
@@ -77,11 +94,27 @@ export const COVERAGE_TOOLS = [
77
94
  // `droveGone` keeps the other honesty: archive/delete/submit remove their own control, so a
78
95
  // drive that WORKED must not read as no coverage at all.
79
96
  const { exercised, droveGone, untouched } = exercisedCount(parseControls(tree), session.actedRefs(), session.actedLabels());
97
+ // The other half of "am I done?". `untouched` answers what you did not DRIVE; this answers
98
+ // what this app cannot ANSWER, which no amount of further driving will change. Reporting the
99
+ // first without the second is how an agent finishes a pass believing it verified something the
100
+ // app was never able to confirm.
101
+ const gaps = session.gaps?.open() ?? [];
102
+ // The number, and the floor under it, together. A coverage figure that can only ever be
103
+ // reported and never contradicted is one an agent learns to satisfy rather than to earn.
104
+ const observability = observabilityOf(session.actedRefs(), gaps);
105
+ const best = await deps.project.bestObservability();
106
+ const regressed = coverageRegressed(best, observability);
107
+ if (observability.percent !== undefined) {
108
+ await deps.project.raiseObservability(observability.percent);
109
+ }
80
110
  return {
81
111
  total: parseControls(tree).length,
82
112
  exercised,
83
113
  untouched,
84
114
  ...(droveGone > 0 ? { alsoDroveGone: droveGone } : {}),
115
+ ...(gaps.length > 0 ? { instrumentationGaps: gaps, unproven: true } : {}),
116
+ observability,
117
+ ...(regressed === undefined ? {} : { observabilityRegressed: regressed }),
85
118
  };
86
119
  },
87
120
  },
@@ -264,7 +264,7 @@ export const OBSERVE_TOOLS = [
264
264
  // that guessed `urlContains` on route (net's spelling) and got unrecognized_keys.
265
265
  'Predicate to evaluate. Kinds: { signal, name } { net, urlContains|method|status|count|bodyContains } ' +
266
266
  '{ state, path|equals } { route, pathname (exact) | contains (path+query+hash) } ' +
267
- '{ element, testid|role|text } { text } { console, level|absent } { animation, name } ' +
267
+ '{ element, testid|role|text } { text } { console, level|contains|absent } { animation, name } ' +
268
268
  '{ settled } — combine with { allOf | anyOf | not }. Prefer a signal/net/state consequence ' +
269
269
  'over element/text presence.'),
270
270
  until: PredicateSchema.optional().describe("Alias for `predicate` (act_and_wait's name)."),
@@ -318,6 +318,10 @@ export const OBSERVE_TOOLS = [
318
318
  .array(z.object({ kind: z.string(), count: z.number() }))
319
319
  .optional()
320
320
  .describe('Which regions were unobservable, when coverage is partial.'),
321
+ instrumentationGaps: z
322
+ .array(z.unknown())
323
+ .optional()
324
+ .describe('What the APP did not tell Reticle, and the one change that would fix it — each entry is { kind, missing, cost, fix, source?, ref? }. Reported ONLY where an absence made THIS verdict weaker: a failing assertion with no source to point at, or a state assertion against an app that registers no store. Never a survey of the page, so an entry is always work worth doing now. OMITTED when the app told Reticle everything it needed.'),
321
325
  },
322
326
  handler: async (deps, args) => {
323
327
  const session = deps.sessions.resolve(asString(args['sessionId']));
@@ -341,11 +345,13 @@ export const OBSERVE_TOOLS = [
341
345
  : assertsDerivedIpcStatus(predicate)
342
346
  ? { advice: DERIVED_IPC_STATUS_ADVICE }
343
347
  : {};
344
- const { decision, contradictions, coverage } = await assertVerdict(session, predicate, verdict.pass, since, verdict.inconclusive, verdict.observationLost);
348
+ const { decision, contradictions, coverage, gaps } = await assertVerdict(session, predicate, verdict.pass, since, verdict.inconclusive, verdict.observationLost);
345
349
  return withControl(session, {
346
350
  ...decision,
347
351
  ...verdict,
348
352
  ...(contradictions.length > 0 ? { contradictions } : {}),
353
+ // What the app did not tell Reticle, on the same rule the act path uses.
354
+ ...(gaps.length > 0 ? { instrumentationGaps: gaps } : {}),
349
355
  ...advice,
350
356
  ...coverage,
351
357
  ...lastActSourceOnFailure(session, verdict.pass),
@@ -11,6 +11,7 @@ import type { RealInputProvider } from '../input/real-input.js';
11
11
  import type { BaselineStore } from '../project/baselines.js';
12
12
  import type { RecordingStore } from '../flows/recordings.js';
13
13
  import type { FileSystemPort } from '../project/fs-port.js';
14
+ import type { ArtifactRoot } from '../project/artifact-root.js';
14
15
  import type { FlowStore } from '../flows/flows.js';
15
16
  import type { ProjectStore } from '../project/project-store.js';
16
17
  import type { AnnotationStore } from '../flows/annotation-store.js';
@@ -39,8 +40,22 @@ export interface ToolDeps<Ext = unknown> {
39
40
  realInput?: RealInputProvider;
40
41
  /** injected filesystem seam (tests pass a fake/temp-dir adapter). */
41
42
  fs: FileSystemPort;
42
- /** absolute.reticle path (index.ts computes cwd/.reticle). */
43
+ /** absolute.reticle path (index.ts computes cwd/.reticle). The FALLBACK, not the answer. */
43
44
  reticleRoot: string;
45
+ /**
46
+ * Which `.reticle` a SESSION's artifacts belong in, resolved per call.
47
+ *
48
+ * `reticleRoot` above is fixed at construction from the daemon's own `process.cwd()`, which is a
49
+ * statement about where the daemon was launched and was being read as a statement about which
50
+ * project is being verified. Those are usually not the same tree: an editor starts a user-scoped
51
+ * MCP server wherever it likes, so flows landed outside the repo they verify, or died on
52
+ * `mkdir '/.reticle'`.
53
+ *
54
+ * Optional because every existing construction of ToolDeps predates it and because a consumer
55
+ * embedding this engine may not have a filesystem to search. Absent ⇒ `reticleRoot`, which is
56
+ * exactly today's behaviour.
57
+ */
58
+ artifactRootFor?: (projectId: string | undefined) => ArtifactRoot;
44
59
  /**
45
60
  * This daemon's OWN project id, derived from the directory it was started in.
46
61
  *
package/docs/README.md CHANGED
@@ -69,6 +69,7 @@ Everything here is published to [docs.reticle.sh](https://docs.reticle.sh) by `d
69
69
  | [vs-screenshots.mdx](vs-screenshots.mdx) | why a better vision model does not fix a non-visual bug |
70
70
  | [telemetry.md](telemetry.md) | what is collected, and how to turn it off |
71
71
  | [local-registry.md](local-registry.md) | installing an unpublished build |
72
+ | [license-activation.mdx](license-activation.mdx) | you have a key: where to set it, how to confirm it, what to do when it fails |
72
73
  | [enterprise.md](enterprise.md) | the licensed surface |
73
74
 
74
75
  ## For people working on Reticle
package/docs/docs.json CHANGED
@@ -32,7 +32,7 @@
32
32
  "navigation": {
33
33
  "versions": [
34
34
  {
35
- "version": "2.10.0",
35
+ "version": "2.11.0",
36
36
  "default": true,
37
37
  "tabs": [
38
38
  {
@@ -183,7 +183,7 @@
183
183
  },
184
184
  {
185
185
  "group": "Trust & licensing",
186
- "pages": ["telemetry", "enterprise"]
186
+ "pages": ["telemetry", "license-activation", "enterprise"]
187
187
  }
188
188
  ]
189
189
  },
@@ -8,6 +8,8 @@ Enterprise features ship inside the open package and are unlocked by a signed li
8
8
 
9
9
  > Premium access (how you get + activate it), what's gated, the security/data-handling posture, and the licensing model. Integration mechanics live in [`platform-integration.md`](./platform-integration.md).
10
10
 
11
+ > **Already have a key and just need to switch it on?** [Activating a license key](./license-activation) is the step-by-step version: where to set it on each platform, the editor and CI cases, how to read every status, and what to do when it does not work.
12
+
11
13
  ## How premium access works (offline, no phone-home)
12
14
 
13
15
  Enterprise (`ee/`) features ship **inside the open package**; they're source-available (free for development, testing, and evaluation). A **license key activates them in production**. Activation is verified locally with Ed25519; nothing about your usage ever leaves your machine.
@@ -29,7 +31,7 @@ Enterprise (`ee/`) features ship **inside the open package**; they're source-ava
29
31
  expired license expired. Renew with hey@reticle.sh to keep using enterprise features
30
32
  ```
31
33
 
32
- Every status also reports `gated` (what a licence unlocks in the build you are running) and `contact`, so the command answers "what would this buy me, and how do I get one" without anybody having to find this page first. `gated` is derived from the features the gate actually enforces, so it lists what your build will really refuse rather than a roadmap.
34
+ Every status reports `contact`, so a reader who is stuck always has somebody to write to. An unlicensed status also reports `gated`, what a licence unlocks in the build you are running, so the command answers "what would this buy me, and how do I get one" without anybody having to find this page first. It is derived from the features the gate actually enforces, so it names what your build will really refuse rather than a roadmap. An `active` licence does not report it: that reader has already bought, and listing feature names at them answers nothing they asked.
33
35
 
34
36
  4. **Unlock.** Enterprise features now run in production; without a valid key they refuse to run there (a clear error, never a silent half-feature). In eval/dev they always run free.
35
37
  5. **Renew.** Keys carry an expiry; `reticle license` warns before it lapses.