@reticlehq/server 2.9.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 (125) hide show
  1. package/SKILL.md +64 -26
  2. package/dist/capsule/capsule-store.d.ts +2 -2
  3. package/dist/cli/cli-parse.d.ts +1 -1
  4. package/dist/cli/cli-parse.js +1 -1
  5. package/dist/cli/cli-verify.js +1 -1
  6. package/dist/crawl/crawl.d.ts +7 -2
  7. package/dist/crawl/crawl.js +52 -9
  8. package/dist/dev/stale-issue-guard.d.ts +43 -0
  9. package/dist/dev/stale-issue-guard.js +75 -0
  10. package/dist/domain/domain-tools.js +3 -2
  11. package/dist/ee/audit-log.js +2 -2
  12. package/dist/events/body-failures.d.ts +2 -2
  13. package/dist/events/contradiction-folds.d.ts +45 -0
  14. package/dist/events/contradiction-folds.js +42 -0
  15. package/dist/events/contradictions.d.ts +19 -1
  16. package/dist/events/contradictions.js +17 -2
  17. package/dist/events/echo-mismatch.d.ts +2 -2
  18. package/dist/events/predicate-asks.d.ts +17 -0
  19. package/dist/events/predicate-asks.js +26 -0
  20. package/dist/events/predicate-eval.js +211 -24
  21. package/dist/events/predicate-parse.js +9 -3
  22. package/dist/events/predicate-schema.d.ts +18 -0
  23. package/dist/events/predicate-schema.js +55 -0
  24. package/dist/events/stale-response.d.ts +2 -3
  25. package/dist/events/stale-response.js +49 -2
  26. package/dist/events/unit-mismatch.d.ts +2 -2
  27. package/dist/flows/flow-tools.js +34 -5
  28. package/dist/flows/predicate-to-expect.js +8 -0
  29. package/dist/flows/verify-change-tools.js +6 -2
  30. package/dist/honesty/blind-spots.d.ts +9 -0
  31. package/dist/honesty/blind-spots.js +17 -4
  32. package/dist/honesty/gap-ledger.d.ts +52 -0
  33. package/dist/honesty/gap-ledger.js +63 -0
  34. package/dist/honesty/instrumentation-gaps.d.ts +55 -0
  35. package/dist/honesty/instrumentation-gaps.js +21 -0
  36. package/dist/honesty/observability.d.ts +37 -0
  37. package/dist/honesty/observability.js +40 -0
  38. package/dist/honesty/verified.js +16 -0
  39. package/dist/index.d.ts +18 -1
  40. package/dist/index.js +57 -0
  41. package/dist/init/mcp.d.ts +1 -2
  42. package/dist/init/mcp.js +4 -1
  43. package/dist/init/node-io.js +3 -0
  44. package/dist/init/run.d.ts +18 -0
  45. package/dist/init/run.js +34 -3
  46. package/dist/init/slash-command.d.ts +1 -1
  47. package/dist/init/slash-command.js +33 -10
  48. package/dist/journal/envelope.d.ts +24 -24
  49. package/dist/license/license.d.ts +76 -5
  50. package/dist/license/license.js +93 -29
  51. package/dist/mcp/mcp-proxy.d.ts +2 -27
  52. package/dist/mcp/mcp-proxy.js +4 -88
  53. package/dist/mcp/mcp.d.ts +18 -2
  54. package/dist/mcp/mcp.js +31 -10
  55. package/dist/mcp/proxy-daemon-probe.d.ts +8 -0
  56. package/dist/mcp/proxy-daemon-probe.js +48 -0
  57. package/dist/mcp/proxy-handshake.js +1 -1
  58. package/dist/mcp/server-instructions.js +2 -2
  59. package/dist/mcp/sse-frame-parser.d.ts +19 -0
  60. package/dist/mcp/sse-frame-parser.js +41 -0
  61. package/dist/project/artifact-root.d.ts +83 -0
  62. package/dist/project/artifact-root.js +112 -0
  63. package/dist/project/memory-fs.d.ts +9 -0
  64. package/dist/project/memory-fs.js +88 -0
  65. package/dist/project/project-store.d.ts +17 -0
  66. package/dist/project/project-store.js +33 -0
  67. package/dist/project/remember-project.d.ts +24 -0
  68. package/dist/project/remember-project.js +40 -0
  69. package/dist/project/session-root.d.ts +15 -0
  70. package/dist/project/session-root.js +31 -0
  71. package/dist/runs/run-tools.js +2 -1
  72. package/dist/session/lease-availability.d.ts +40 -0
  73. package/dist/session/lease-availability.js +43 -0
  74. package/dist/session/no-session-diagnosis.d.ts +17 -0
  75. package/dist/session/no-session-diagnosis.js +41 -8
  76. package/dist/session/no-session-watch.js +7 -0
  77. package/dist/session/session-readiness.js +1 -1
  78. package/dist/session/session.d.ts +9 -0
  79. package/dist/session/session.js +9 -0
  80. package/dist/session/stall-clock.d.ts +18 -0
  81. package/dist/session/stall-clock.js +48 -0
  82. package/dist/telemetry/daemon-telemetry.js +16 -22
  83. package/dist/telemetry/license-activation.d.ts +48 -0
  84. package/dist/telemetry/license-activation.js +57 -0
  85. package/dist/telemetry/session-metrics.d.ts +1 -1
  86. package/dist/telemetry/session-metrics.js +1 -1
  87. package/dist/telemetry/telemetry.d.ts +12 -3
  88. package/dist/telemetry/telemetry.js +34 -5
  89. package/dist/tools/act-tools.js +34 -4
  90. package/dist/tools/assert-verdict.d.ts +2 -0
  91. package/dist/tools/assert-verdict.js +24 -1
  92. package/dist/tools/contract-tools.js +5 -3
  93. package/dist/tools/coverage-tools.js +33 -0
  94. package/dist/tools/dynamic-tools.js +1 -1
  95. package/dist/tools/error-recovery.js +1 -1
  96. package/dist/tools/feedback-tools.js +31 -6
  97. package/dist/tools/invoke-tool.d.ts +1 -1
  98. package/dist/tools/invoke-tool.js +10 -5
  99. package/dist/tools/observe-tools.js +8 -2
  100. package/dist/tools/tool-kit.d.ts +46 -4
  101. package/dist/tools/tool-names.d.ts +2 -0
  102. package/dist/tools/tool-names.js +2 -0
  103. package/dist/tools/tool-surface.js +2 -3
  104. package/dist/tools/tools.js +16 -1
  105. package/docs/README.md +1 -0
  106. package/docs/agent-cheatsheet.md +2 -2
  107. package/docs/capabilities.mdx +4 -4
  108. package/docs/cli/hunt.mdx +2 -2
  109. package/docs/deploy-checks.md +2 -2
  110. package/docs/docs.json +2 -2
  111. package/docs/enterprise.md +9 -3
  112. package/docs/flows.md +4 -4
  113. package/docs/gates.md +1 -1
  114. package/docs/license-activation.mdx +145 -0
  115. package/docs/recipes.mdx +2 -2
  116. package/docs/telemetry-contract.md +18 -12
  117. package/docs/telemetry-events.mdx +1 -34
  118. package/docs/telemetry.md +0 -1
  119. package/docs/tools-overview.mdx +1 -1
  120. package/docs/tools-session-and-feedback.mdx +5 -0
  121. package/docs/tools-tools-and-run.mdx +15 -15
  122. package/docs/usage.md +3 -3
  123. package/package.json +2 -2
  124. package/dist/telemetry/instrumentation-stall.d.ts +0 -50
  125. package/dist/telemetry/instrumentation-stall.js +0 -90
@@ -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
  },
@@ -75,7 +75,7 @@ export function buildDynamicTools(allTools, profile) {
75
75
  };
76
76
  const reticleTools = {
77
77
  name: ReticleTool.TOOLS,
78
- description: 'Discover Reticle tools on demand. Call with no arguments to list every tool (name + one-line summary); call with names:["reticle_network", …] to load full descriptions and parameters for specific tools. Then invoke them with reticle_run. This avoids paying for every tool definition on every turn. To make a verification REUSABLE (record once, replay free forever), the flow workflow lives here: reticle_record{action:"start"} → act → reticle_flow_save → reticle_flow_verify (and reticle_flow_heal on drift). Load those names when you want to save or re-run a flow.',
78
+ description: 'Discover Reticle tools on demand. Call with no arguments to list every tool (name + one-line summary); call with names:["reticle_network", …] to load full descriptions and parameters for specific tools. Then invoke them with reticle_run. This avoids paying for every tool definition on every turn. To make a verification REUSABLE (record once, replay free forever), the flow workflow lives here: reticle_record{action:"start"} → act → reticle_flow_save → reticle_verify{action:"flows"} (and reticle_flow_heal on drift). Load those names when you want to save or re-run a flow.',
79
79
  inputSchema: {
80
80
  names: z
81
81
  .array(z.string())
@@ -129,7 +129,7 @@ export const RECOVERY = {
129
129
  'synchronous task, an alert/confirm dialog); reticle_console usually shows what it hit. Retry ' +
130
130
  'once the cause is addressed.',
131
131
  FLOW_STEP_MISSING: 'A recorded step points at an element that is not on the page now. Either the app changed under ' +
132
- 'the flow — run reticle_flow_heal to re-anchor it, then reticle_flow_verify — or an earlier step ' +
132
+ 'the flow — run reticle_flow_heal to re-anchor it, then reticle_verify{action:"flows"} — or an earlier step ' +
133
133
  'did not do what it used to, leaving the run on the wrong page: check reticle_snapshot to see ' +
134
134
  'where it actually is. This is app/flow drift, which is exactly what the flow exists to catch.',
135
135
  NO_POOL: 'Reticle cannot launch its own browser here: the daemon-managed pool is absent, or Playwright ' +
@@ -12,9 +12,9 @@
12
12
  * tokens on that and not on politeness.
13
13
  */
14
14
  import { z } from 'zod';
15
- import { FeedbackKind, FeedbackSource } from '@reticlehq/core';
15
+ import { FeedbackKind, FeedbackSource, FEEDBACK_RATING_MAX, FEEDBACK_RATING_MIN, } from '@reticlehq/core';
16
16
  import { ReticleTool } from './tool-names.js';
17
- import { asString } from './tools-helpers.js';
17
+ import { asNumber, asString } from './tools-helpers.js';
18
18
  import { submitFeedback, feedbackDisabled } from '../telemetry/feedback.js';
19
19
  import { getTelemetry } from '../telemetry/telemetry.js';
20
20
  /** One optional string arg, present only when the agent actually supplied it. */
@@ -22,6 +22,19 @@ function optionalText(args, key) {
22
22
  const value = asString(args[key]);
23
23
  return value !== undefined && value !== '' ? { [key]: value } : {};
24
24
  }
25
+ /**
26
+ * The star rating, present only when the agent actually supplied one, and only when it is on the
27
+ * scale core defines. Bounds-checked HERE as well as in the schema because a number that survives
28
+ * to the wire off-scale is worse than an absent one: it still averages.
29
+ */
30
+ function optionalRating(args) {
31
+ const value = asNumber(args['rating']);
32
+ if (value === undefined || !Number.isInteger(value))
33
+ return {};
34
+ if (value < FEEDBACK_RATING_MIN || value > FEEDBACK_RATING_MAX)
35
+ return {};
36
+ return { rating: value };
37
+ }
25
38
  /** Read the connected session's self-reported facts, or nothing when no app is attached. */
26
39
  function sessionFacts(deps, sessionId) {
27
40
  const driven = deps.realInput !== undefined;
@@ -45,7 +58,7 @@ function sessionFacts(deps, sessionId) {
45
58
  export const FEEDBACK_TOOLS = [
46
59
  {
47
60
  name: ReticleTool.FEEDBACK,
48
- description: "Tell the Reticle maintainers something — a failure, OR a feature you wish existed. Reticle is built FOR agents, so your experience is the one that decides what gets built, and this is the only channel that reaches us. File a `bug` when a tool returned something wrong, a `gap` when Reticle could not observe what you needed, an `ambiguity` when a verdict was not decidable, a `feature_request` when you thought 'I wish Reticle could do X', or an `improvement` when something works but is awkward (too many calls, a confusing shape, a slow path). Do not save these up or judge them as too minor — file it the moment you notice, then carry on with your task. For a failure, write `text` as a root-cause analysis (what you called, expected, got). For a request, write what you want and use `need` for the GOAL behind it, `impact` for what gets better, and `currentApproach` for how you are working around it today — that workaround is usually the most useful thing in the report. Set `model` to the model you are running. Never include app source, secrets, user data, or customer records. Do NOT use this for bugs you found in the app under test — that is a successful verification, which is Reticle working.",
61
+ description: "Tell the Reticle maintainers something — a failure, OR a feature you wish existed. Reticle is built FOR agents, so your experience is the one that decides what gets built, and this is the only channel that reaches us. File a `bug` when a tool returned something wrong, a `gap` when Reticle could not observe what you needed, an `ambiguity` when a verdict was not decidable, a `feature_request` when you thought 'I wish Reticle could do X', an `improvement` when something works but is awkward (too many calls, a confusing shape, a slow path), or an `experience` when something worked notably WELL (carry `rating` 1-5). File that one unprompted, when you actually notice it, and say which call and what it saved you — every other kind here is a complaint, so it is the only evidence for what is worth protecting when we change things. Do not save these up or judge them as too minor — file it the moment you notice, then carry on with your task. For a failure, write `text` as a root-cause analysis (what you called, expected, got). For a request, write what you want and use `need` for the GOAL behind it, `impact` for what gets better, and `currentApproach` for how you are working around it today — that workaround is usually the most useful thing in the report. Set `model` to the model you are running. Never include app source, secrets, user data, or customer records. Do NOT use this for bugs you found in the app under test — that is a successful verification, which is Reticle working.",
49
62
  inputSchema: {
50
63
  kind: z
51
64
  .enum([
@@ -54,8 +67,9 @@ export const FEEDBACK_TOOLS = [
54
67
  FeedbackKind.AMBIGUITY,
55
68
  FeedbackKind.FEATURE_REQUEST,
56
69
  FeedbackKind.IMPROVEMENT,
70
+ FeedbackKind.EXPERIENCE,
57
71
  ])
58
- .describe('bug = a Reticle tool returned a wrong or broken result; gap = Reticle could not observe something you needed at all; ambiguity = the verification ran but you could not tell pass from fail; feature_request = something that does not exist and would have helped; improvement = something that exists but is awkward or inefficient.'),
72
+ .describe('bug = a Reticle tool returned a wrong or broken result; gap = Reticle could not observe something you needed at all; ambiguity = the verification ran but you could not tell pass from fail; feature_request = something that does not exist and would have helped; improvement = something that exists but is awkward or inefficient; experience = something worked notably well and is worth keeping (pair it with `rating`).'),
59
73
  text: z
60
74
  .string()
61
75
  .min(1)
@@ -72,6 +86,13 @@ export const FEEDBACK_TOOLS = [
72
86
  .string()
73
87
  .optional()
74
88
  .describe('How you work around it TODAY. Usually the most useful field in the report: it proves the need is real, and often shows a cheaper fix than the feature being asked for.'),
89
+ rating: z
90
+ .number()
91
+ .int()
92
+ .min(FEEDBACK_RATING_MIN)
93
+ .max(FEEDBACK_RATING_MAX)
94
+ .optional()
95
+ .describe(`How well it worked, ${FEEDBACK_RATING_MIN}-${FEEDBACK_RATING_MAX}, with an \`experience\`. Only when you formed the judgement while working, never because you were asked: an agreeable number and an earned one are indistinguishable once both sit in the same column. The \`text\` is the part we can act on.`),
75
96
  model: z
76
97
  .string()
77
98
  .optional()
@@ -112,6 +133,7 @@ export const FEEDBACK_TOOLS = [
112
133
  ...optionalText(args, 'impact'),
113
134
  ...optionalText(args, 'currentApproach'),
114
135
  ...optionalText(args, 'model'),
136
+ ...optionalRating(args),
115
137
  }, {
116
138
  session: sessionFacts(deps, asString(args['sessionId'])),
117
139
  // The AGENT does not wait for the network. `reticle feedback` (a human, at a terminal,
@@ -154,8 +176,11 @@ export const FEEDBACK_PROMPT = {
154
176
  export const VERIFICATION_TOOLS = new Set([
155
177
  ReticleTool.ACT_AND_WAIT,
156
178
  ReticleTool.ASSERT,
157
- ReticleTool.FLOW_VERIFY,
158
- ReticleTool.VERIFY_CHANGE,
179
+ // Merged: `flow_verify` and `verify_change` are actions on this tool now. Naming the merged tool
180
+ // does NOT make every action a verification — `verificationOf` still requires the result to carry
181
+ // a `verified` or a pass/fail `status`, so `coverage` and `crawl` contribute nothing and cannot
182
+ // inflate the count.
183
+ ReticleTool.VERIFY,
159
184
  ReticleTool.RECONCILE,
160
185
  ]);
161
186
  /**
@@ -7,4 +7,4 @@ export declare const SESSION_BOUND_TOOLS: ReadonlySet<string>;
7
7
  * `sessionId`-bearing tool to be classified into exactly one set (bound XOR exempt).
8
8
  */
9
9
  export declare const SESSION_EXEMPT_TOOLS: ReadonlySet<string>;
10
- export declare function runTool(tool: ToolDef, deps: ToolDeps, args: Record<string, unknown>): Promise<unknown>;
10
+ export declare function runTool<Ext>(tool: ToolDef<Ext>, deps: ToolDeps<Ext>, args: Record<string, unknown>): Promise<unknown>;
@@ -47,14 +47,21 @@ const REF_MINTING_TOOLS = new Set([
47
47
  ReticleTool.QUERY,
48
48
  ReticleTool.INSPECT,
49
49
  ReticleTool.EXPLORE,
50
- ReticleTool.CRAWL,
50
+ // `crawl` mints refs for the controls it drives and `coverage` for the ones it reports untouched.
51
+ // Both are actions on the merged verify tool now.
52
+ ReticleTool.VERIFY,
51
53
  ]);
52
54
  export const SESSION_BOUND_TOOLS = new Set([
53
55
  ReticleTool.SNAPSHOT,
54
56
  ReticleTool.QUERY,
55
57
  ReticleTool.INSPECT,
56
- ReticleTool.COVERAGE,
57
- ReticleTool.VERIFY_CHANGE,
58
+ // Merged change/flows/affected/coverage/crawl. Its members were SPLIT across bound and exempt —
59
+ // coverage/change/crawl bound, flow_verify exempt because it returns its own suite contract — and
60
+ // a merged tool has to be one or the other. Bound is the right side: `crawl` drives the app
61
+ // itself, so a throttled or event-dropping tab is exactly the condition its verdict depends on,
62
+ // and dropping the health disclosure there to protect a sibling's output shape would trade an
63
+ // honesty signal for a formatting preference.
64
+ ReticleTool.VERIFY,
58
65
  ReticleTool.ACT,
59
66
  ReticleTool.ACT_SEQUENCE,
60
67
  ReticleTool.ACT_AND_WAIT,
@@ -74,7 +81,6 @@ export const SESSION_BOUND_TOOLS = new Set([
74
81
  ReticleTool.STATE,
75
82
  ReticleTool.STORAGE,
76
83
  ReticleTool.EXPLORE,
77
- ReticleTool.CRAWL,
78
84
  ReticleTool.SCROLL_TO,
79
85
  ReticleTool.NAVIGATE,
80
86
  ]);
@@ -90,7 +96,6 @@ export const SESSION_EXEMPT_TOOLS = new Set([
90
96
  ReticleTool.FLOW_SAVE, // sessionId only scopes the write to the app's flow subdir; disk-side
91
97
  ReticleTool.FLOW, // merged list/load/delete — sessionId only scopes the project; all disk-side
92
98
  ReticleTool.FLOW_REPLAY, // returns its own FlowReplayResult contract (+ auto-records a run)
93
- ReticleTool.FLOW_VERIFY, // returns its own SuiteVerdict contract (replays the whole suite)
94
99
  ReticleTool.FLOW_SAVE_RECORDED, // reads the recording buffer, writes disk
95
100
  ReticleTool.FLOW_HEAL, // returns its own FlowHealResult contract
96
101
  ReticleTool.PROJECT, // reads .reticle/project.json
@@ -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,12 +11,13 @@ 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';
17
18
  import type { BrowserPool } from '../pool/browser-pool.js';
18
19
  import type { ChromiumProbe } from '../cli/chromium-hint.js';
19
- export interface ToolDeps {
20
+ export interface ToolDeps<Ext = unknown> {
20
21
  sessions: SessionManager;
21
22
  /** shared one-browser/N-context pool for headless leases. undefined ⇒ lease tools report unavailable. */
22
23
  pool?: BrowserPool;
@@ -39,8 +40,22 @@ export interface ToolDeps {
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
  *
@@ -59,8 +74,23 @@ export interface ToolDeps {
59
74
  * ToolDeps keeps working; absent falls back to the default port.
60
75
  */
61
76
  bridgePort?: number;
77
+ /**
78
+ * A consumer's OWN dependencies, carried through untouched.
79
+ *
80
+ * Nothing in this package reads it. It exists so a consumer embedding the engine can add tools of
81
+ * its own — answering out of a store this package has never heard of — without either widening the
82
+ * fields above with a stranger's vocabulary or forking to get a handle through.
83
+ *
84
+ * The type parameter, rather than `unknown`, is what keeps the seam typed: a consumer writes
85
+ * `ToolDef<TheirExt>` once and their handlers see their own type. Casting out of `unknown` in every
86
+ * handler is how a typed seam quietly becomes an untyped one.
87
+ *
88
+ * Optional, and defaulted to `unknown`, so every existing construction of this bag — and every test
89
+ * that builds one — is unchanged.
90
+ */
91
+ ext?: Ext;
62
92
  }
63
- export interface ToolDef {
93
+ export interface ToolDef<Ext = unknown> {
64
94
  name: string;
65
95
  description: string;
66
96
  inputSchema: z.ZodRawShape;
@@ -86,7 +116,19 @@ export interface ToolDef {
86
116
  * example that does not validate is worse than none at all.
87
117
  */
88
118
  example?: Record<string, unknown>;
89
- handler: (deps: ToolDeps, args: Record<string, unknown>) => Promise<unknown>;
119
+ /**
120
+ * Declared method-style, not as a property, and that is load-bearing rather than stylistic.
121
+ *
122
+ * Under `strictFunctionTypes` a property holding a function has CONTRAVARIANT parameters, so
123
+ * `ToolDef<TheirExt>` and `ToolDef<unknown>` become mutually unassignable and a consumer composing
124
+ * `[...TOOLS, ...theirTools]` needs a cast — as does every internal helper that then touches the
125
+ * array. Method-style parameters are checked bivariantly, which collapses all of that.
126
+ *
127
+ * The unsoundness bivariance admits is that a handler written against `ToolDeps<TheirExt>` could be
128
+ * handed a bag carrying no `ext` at all. That costs nothing here, because `ext` is optional by
129
+ * design: any handler reading it already has to survive `undefined`.
130
+ */
131
+ handler(deps: ToolDeps<Ext>, args: Record<string, unknown>): Promise<unknown>;
90
132
  }
91
133
  export declare const sessionIdShape: {
92
134
  sessionId: z.ZodOptional<z.ZodString>;
@@ -46,6 +46,8 @@ export declare const ReticleTool: {
46
46
  readonly AFFECTED: "reticle_affected";
47
47
  readonly COVERAGE: "reticle_coverage";
48
48
  readonly VERIFY_CHANGE: "reticle_verify_change";
49
+ /** Merged: change/flows/affected/coverage/crawl — "what is proved, and what is not". */
50
+ readonly VERIFY: "reticle_verify";
49
51
  /** read cross-run history (.reticle/project.json) + diff-vs-last for a name. */
50
52
  readonly PROJECT: "reticle_project";
51
53
  /** explicitly record a run outcome (the manual companion to auto-recording). */
@@ -47,6 +47,8 @@ export const ReticleTool = {
47
47
  AFFECTED: 'reticle_affected',
48
48
  COVERAGE: 'reticle_coverage',
49
49
  VERIFY_CHANGE: 'reticle_verify_change',
50
+ /** Merged: change/flows/affected/coverage/crawl — "what is proved, and what is not". */
51
+ VERIFY: 'reticle_verify',
50
52
  /** read cross-run history (.reticle/project.json) + diff-vs-last for a name. */
51
53
  PROJECT: 'reticle_project',
52
54
  /** explicitly record a run outcome (the manual companion to auto-recording). */
@@ -206,15 +206,14 @@ export const EXTENDED_TOOL_NAMES = new Set([
206
206
  ReticleTool.CAPABILITIES,
207
207
  ReticleTool.FLOW_SAVE,
208
208
  ReticleTool.FLOW_REPLAY,
209
- ReticleTool.FLOW_VERIFY,
210
209
  ReticleTool.RECORD,
211
210
  ReticleTool.SCREENSHOT,
212
211
  ReticleTool.VISUAL_DIFF,
213
212
  ReticleTool.CLOCK,
214
213
  ReticleTool.NETWORK_MOCK,
215
214
  ReticleTool.STORAGE,
216
- ReticleTool.VERIFY_CHANGE,
217
- ReticleTool.CRAWL,
215
+ // Merged change/flows/affected/coverage/crawl — one name where three of these used to sit.
216
+ ReticleTool.VERIFY,
218
217
  ]);
219
218
  /**
220
219
  * The verify surface: one acting tool that returns a verdict, plus the two meta-tools that reach
@@ -508,7 +508,7 @@ export const MERGE_PLANS = [
508
508
  },
509
509
  {
510
510
  name: ReticleTool.FLOW,
511
- description: 'Saved-flow management: { action: "list" } names every saved flow, "load" reads+validates one by flowName, "delete" removes one. The hot verbs (flow_save, flow_replay, flow_verify, flow_heal) stay separate.',
511
+ description: 'Saved-flow management: { action: "list" } names every saved flow, "load" reads+validates one by flowName, "delete" removes one. The hot verbs (flow_save, flow_replay, flow_heal) stay separate; whole-suite replay is reticle_verify { action: "flows" }.',
512
512
  members: {
513
513
  list: ReticleTool.FLOW_LIST,
514
514
  load: ReticleTool.FLOW_LOAD,
@@ -531,6 +531,21 @@ export const MERGE_PLANS = [
531
531
  // is the one that must not need a schema lookup first.
532
532
  example: { action: 'yield', mode: 'waiting' },
533
533
  },
534
+ {
535
+ name: ReticleTool.VERIFY,
536
+ description: 'What is proved, and what is not — by action. Start from a change: { action: "affected" } names which saved flows must re-verify for the files you edited (pass `files`, and/or `since` for a git ref), and "change" goes further and actually replays them, answering with one `verified` plus `because`. `unknown` there is the honest answer when NO saved flow covers the change: nothing ran, so nothing was proved, and it is never reported as green. Without a change to start from: "flows" replays every saved flow for one consolidated suite verdict (deterministic, no LLM per flow), and "coverage" lists the interactive controls you have and have NOT driven this session — an untouched list still holding the controls your change affects means you are not done. "crawl" is the no-script option: it drives every reachable control itself and reports single-channel faults (console errors, failed requests, dead controls) and CONTRADICTIONS, two channels disagreeing about the same click — the false greens a human cannot see, because a human watches the screen and the screen looks correct. DESTRUCTIVE: crawl really clicks, and may navigate or mutate state.',
537
+ members: {
538
+ change: ReticleTool.VERIFY_CHANGE,
539
+ flows: ReticleTool.FLOW_VERIFY,
540
+ affected: ReticleTool.AFFECTED,
541
+ coverage: ReticleTool.COVERAGE,
542
+ crawl: ReticleTool.CRAWL,
543
+ },
544
+ // No default action ON PURPOSE. The name implies no single member, and one of them really
545
+ // clicks: a fallthrough that guessed wrong would drive the app rather than read it. Every action
546
+ // is explicit, which for `crawl` is the whole safety story.
547
+ example: { action: 'change', files: ['src/App.tsx'] },
548
+ },
534
549
  {
535
550
  name: ReticleTool.LEASE,
536
551
  description: 'Isolated headless contexts from the shared pool: { action: "acquire" } leases one and navigates it to the app URL, "release" closes it and frees the slot.',
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
@@ -115,7 +115,7 @@ The tools advertised DIRECTLY, i.e. what you'll use 90% of the time. Everything
115
115
 
116
116
  Frequently useful but NOT core, so reach them through `reticle_run({ tool, args })`: `reticle_capabilities` (the app's whole testable surface in one call; `{fromDisk:true}` needs no browser), `reticle_domain` (learn the app + gaps), `reticle_baseline {action:"diff"}`, `reticle_project` (run history).
117
117
 
118
- **Reach past core when…** you need to record/replay a journey (`reticle_record {action:"start"}/stop`, `reticle_replay`), persist a self-healing golden flow (`reticle_flow_save*` / `reticle_flow_replay` / `reticle_flow_heal`), compile annotations (`reticle_annotate`), explore autonomously (`reticle_explore` lists controls; `reticle_crawl` clicks them all and reports anomalies, and is **destructive**), reveal a virtualized off-screen row (`reticle_scroll_to`, for when `reticle_query` finds nothing because a windowed list hasn't rendered it yet), visual-check (`reticle_screenshot` / `reticle_visual_diff`, pinned with `reticle_viewport` for reproducible baselines), test error/edge states by stubbing the network (`reticle_network_mock`: 500 / offline / delay, driven mode), control time for toasts/debounces/auto-dismiss (`reticle_clock { freeze | advanceMs | reset }`), or work with a human (`reticle_session {action:"end"}` / `reticle_session {action:"resume"}` / `reticle_session {action:"messages"}`, and **`reticle_session {action:"review"}`** to drain + fix the bugs the human flagged from the panel).
118
+ **Reach past core when…** you need to record/replay a journey (`reticle_record {action:"start"}/stop`, `reticle_replay`), persist a self-healing golden flow (`reticle_flow_save*` / `reticle_flow_replay` / `reticle_flow_heal`), compile annotations (`reticle_annotate`), explore autonomously (`reticle_explore` lists controls; `reticle_verify {action:"crawl"}` clicks them all and reports anomalies, and is **destructive**), reveal a virtualized off-screen row (`reticle_scroll_to`, for when `reticle_query` finds nothing because a windowed list hasn't rendered it yet), visual-check (`reticle_screenshot` / `reticle_visual_diff`, pinned with `reticle_viewport` for reproducible baselines), test error/edge states by stubbing the network (`reticle_network_mock`: 500 / offline / delay, driven mode), control time for toasts/debounces/auto-dismiss (`reticle_clock { freeze | advanceMs | reset }`), or work with a human (`reticle_session {action:"end"}` / `reticle_session {action:"resume"}` / `reticle_session {action:"messages"}`, and **`reticle_session {action:"review"}`** to drain + fix the bugs the human flagged from the panel).
119
119
 
120
120
  ## flows vs baselines vs project.json (the persistence layers)
121
121
 
@@ -160,4 +160,4 @@ Recent additions, each of which answers a question agents were previously asking
160
160
  - **Cap broad reads.** `reticle_query` takes `limit` (caps descriptors; reports `total`/`truncated`) and `count_only` (just the match count). `reticle_network` / `reticle_console` take `limit` (most-recent-N, reports `droppedOldest`) and carry the same `cost` hint, so a busy page or wide window never floods your context unnoticed.
161
161
  - **A saved flow tells you if it's a real test.** `reticle_flow_save` returns `assertions.grade` (`asserted` / `presence-only` / `assertion-free`); if it's not `asserted`, add a consequence (`reticle_annotate` assert-signal/assert-net or a success-state) so it can't pass while broken. On replay, an ambiguous heal (two testids tie) is surfaced, never auto-applied. And an `apply` heal re-replays the rebound flow and **refuses to write** if the success consequence no longer fires (`status:consequence_broken`): it heals the locator, never the intent.
162
162
  - **Predicate schema is not bloated.** The recursive predicate DSL used by `reticle_assert` / `reticle_wait_for` / `reticle_act_and_wait` is **factored, not inlined**: when converted to the JSON Schema MCP sends, the predicate body is emitted **once** (~2.7k chars ≈ **~685 tokens** per tool) and recursion is handled by self-`$ref` (`#/properties/predicate`), with no per-recursion duplication. No action needed.
163
- - **One tool surface.** Reticle advertises the core set directly (sessions/navigate/snapshot/query/act/act_and_wait/observe/network/console/wait_for/assert/state/inspect/feedback/session: the whole detect loop, plus the file-pointer, the feedback channel and the handback) PLUS two meta-tools that keep every other tool one call away: `reticle_tools` (discover; no args lists every tool name + summary, `names:[…]` loads full params on demand) and `reticle_run({ tool, args })` (invoke any tool by name; a top-level `sessionId` is forwarded to the target, so you need not nest it in `args`). So to record/replay/verify a flow, call `reticle_run({ tool:"reticle_flow_verify", sessionId })` (or `reticle_tools` first to see params). There is nothing to pick. `RETICLE_ADVERTISE_ALL_TOOLS=1` (read by the daemon at startup, so restart it) advertises everything with output schemas: a verification switch for suites, not a way to run agents. **Sizes are deliberately not quoted here.** A count in prose goes stale, and has three times already. `reticle_tools` reports the live surface, and SKILL.md carries the one gated table.
163
+ - **One tool surface.** Reticle advertises the core set directly (sessions/navigate/snapshot/query/act/act_and_wait/observe/network/console/wait_for/assert/state/inspect/feedback/session: the whole detect loop, plus the file-pointer, the feedback channel and the handback) PLUS two meta-tools that keep every other tool one call away: `reticle_tools` (discover; no args lists every tool name + summary, `names:[…]` loads full params on demand) and `reticle_run({ tool, args })` (invoke any tool by name; a top-level `sessionId` is forwarded to the target, so you need not nest it in `args`). So to record/replay/verify a flow, call `reticle_run({ tool:"reticle_verify", args:{ action:"flows" }, sessionId })` (or `reticle_tools` first to see params). There is nothing to pick. `RETICLE_ADVERTISE_ALL_TOOLS=1` (read by the daemon at startup, so restart it) advertises everything with output schemas: a verification switch for suites, not a way to run agents. **Sizes are deliberately not quoted here.** A count in prose goes stale, and has three times already. `reticle_tools` reports the live surface, and SKILL.md carries the one gated table.