@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
@@ -91,6 +91,49 @@ function flights(events) {
91
91
  */
92
92
  const MAX_FLIGHTS_PER_PATH = 60;
93
93
  /** The one contradiction, or none. Reported once per window — a race is a fact about the window. */
94
+ /**
95
+ * How long after a superseded response lands we look for the app reacting to it.
96
+ *
97
+ * A handler runs on the microtask that resolves the fetch; the render follows within a frame or two.
98
+ * Beyond this the connection between the response and any change is speculation.
99
+ */
100
+ const APPLY_WINDOW_MS = 500;
101
+ /**
102
+ * How far BEFORE the completion stamp a reaction still counts as reacting to it.
103
+ *
104
+ * Not a fudge factor — a correction for which of the two is stamped first. `NET_REQUEST` is emitted
105
+ * when the observer's wrapper sees the fetch settle, and the app's own handler runs on the same
106
+ * microtask; in practice the app's state write, signal and DOM text land at or a millisecond BEFORE
107
+ * the completion event that caused them.
108
+ *
109
+ * So a strictly-forward window could not see the application at all, and the rule could never fire on
110
+ * real traffic however plainly the app misbehaved. Sixteen passing unit tests did not catch that,
111
+ * because synthetic events are written with the reaction placed after the cause.
112
+ */
113
+ const APPLY_SLACK_MS = 100;
114
+ /**
115
+ * Did the app REACT to this response — did anything user-visible move after it landed?
116
+ *
117
+ * The question the rule could not previously ask, and the difference between a defect and a
118
+ * correctly-handled race. An app that guards against superseded responses still RACES: two requests
119
+ * overlap and settle out of order exactly as before. What it does not do is act on the loser.
120
+ *
121
+ * Without this check the rule fires identically on both, which is worse than not having it: a
122
+ * detector that accuses correct code teaches people to ignore it, and it takes the true positives
123
+ * down with it.
124
+ */
125
+ function appliedAfter(events, settledAt) {
126
+ return events.some((event) => {
127
+ if (event.t < settledAt - APPLY_SLACK_MS || event.t > settledAt + APPLY_WINDOW_MS)
128
+ return false;
129
+ return (event.type === EventType.STATE_CHANGE ||
130
+ event.type === EventType.SIGNAL ||
131
+ event.type === EventType.DOM_TEXT ||
132
+ event.type === EventType.DOM_ADDED ||
133
+ event.type === EventType.DOM_REMOVED ||
134
+ event.type === EventType.DOM_ATTR);
135
+ });
136
+ }
94
137
  export function findStaleResponses(events) {
95
138
  // Grouped by path first: two reads of DIFFERENT endpoints can never race, and comparing them was
96
139
  // the bulk of the work on any real page, which talks to many endpoints at once.
@@ -105,13 +148,13 @@ export function findStaleResponses(events) {
105
148
  }
106
149
  for (const group of byPath.values()) {
107
150
  const reads = group.slice(-MAX_FLIGHTS_PER_PATH);
108
- const found = raceIn(reads);
151
+ const found = raceIn(reads, events);
109
152
  if (found !== undefined)
110
153
  return [found];
111
154
  }
112
155
  return [];
113
156
  }
114
- function raceIn(reads) {
157
+ function raceIn(reads, events) {
115
158
  for (const first of reads) {
116
159
  for (const second of reads) {
117
160
  if (first.id === second.id)
@@ -127,6 +170,10 @@ function raceIn(reads) {
127
170
  // Parallel pagination is not a race — see differsOnlyByEnumeration.
128
171
  if (differsOnlyByEnumeration(queryOf(first.url), queryOf(second.url)))
129
172
  continue;
173
+ // The race happened. Whether it MATTERS is whether the app acted on the loser — an app that
174
+ // drops superseded responses races just as visibly and is not broken.
175
+ if (!appliedAfter(events, first.settledAt))
176
+ continue;
130
177
  return {
131
178
  kind: ContradictionKind.STALE_RESPONSE_APPLIED,
132
179
  claim: `both reads of ${pathOf(first.url)} returned successfully and the page settled`,
@@ -1,5 +1,5 @@
1
1
  import { type ReticleEvent } from '@reticlehq/core';
2
- import type { Contradiction } from './contradictions.js';
2
+ import type { OwnContradiction } from './contradictions.js';
3
3
  /**
4
4
  * @param prior events from BEFORE the window, used only to learn what the API has stated.
5
5
  *
@@ -13,4 +13,4 @@ import type { Contradiction } from './contradictions.js';
13
13
  * every other rule blame this action for events it did not cause, and would re-report every earlier
14
14
  * mistake on every assert.
15
15
  */
16
- export declare function findUnitMismatches(events: readonly ReticleEvent[], prior?: readonly ReticleEvent[]): Contradiction[];
16
+ export declare function findUnitMismatches(events: readonly ReticleEvent[], prior?: readonly ReticleEvent[]): OwnContradiction[];
@@ -14,6 +14,7 @@ import { resolveProjectCloud } from '../cloud/cloud-config.js';
14
14
  import { buildSuiteVerdict } from './decision.js';
15
15
  import { classifyFlowAssertions } from './flow-classify.js';
16
16
  import { isValidFlowName, flowPath } from '../project/reticle-dir.js';
17
+ import { FlowStore } from './flows.js';
17
18
  import { replayNamedFlow, flowErrorMessage, latestRecordedFlow, sessionProjectId, } from './flow-replay-run.js';
18
19
  import { persistAndSyncVerificationRun } from '../runs/verification-sync.js';
19
20
  import { runServerVerify } from './server-verify.js';
@@ -67,6 +68,24 @@ export function leaseFailureReplay(name, error) {
67
68
  }
68
69
  /** Stand-in path for a flow whose name fails the path-segment guard — never a real join. */
69
70
  const INVALID_FLOW_NAME_PATH = '(invalid flow name — not a usable path)';
71
+ /**
72
+ * The flow store a SESSION's artifacts belong in, and the root it resolved to.
73
+ *
74
+ * `deps.flows` is bound to the daemon's own root at construction, which is right only when the
75
+ * daemon happens to have been started in the project it is driving. When a resolver is wired and it
76
+ * names a different project, the write has to go there instead — so the store is rebound for this
77
+ * call. Rebinding is cheap: a FlowStore is a filesystem port, a root and a clock.
78
+ *
79
+ * Returns `deps.flows` unchanged when nothing is wired or the roots already agree, so the common
80
+ * case allocates nothing and behaves exactly as before.
81
+ */
82
+ function flowsForSession(deps, projectId) {
83
+ const resolved = deps.artifactRootFor?.(projectId);
84
+ if (resolved === undefined || resolved.root === deps.reticleRoot) {
85
+ return { flows: deps.flows, root: deps.reticleRoot };
86
+ }
87
+ return { flows: new FlowStore(deps.fs, resolved.root, { now: deps.now }), root: resolved.root };
88
+ }
70
89
  export const FLOW_TOOLS = [
71
90
  {
72
91
  name: ReticleTool.FLOW_SAVE,
@@ -132,16 +151,26 @@ export const FLOW_TOOLS = [
132
151
  const emptyRefusal = emptyFlowRefusal(program.steps.length, name);
133
152
  if (emptyRefusal !== undefined)
134
153
  return Promise.resolve(emptyRefusal);
135
- return deps.flows.save(program, annotations, projectId).then(async (res) => {
154
+ const { flows, root } = flowsForSession(deps, projectId);
155
+ return flows.save(program, annotations, projectId).then(async (res) => {
136
156
  if (!res.ok)
137
157
  return { error: flowErrorMessage(res.code), code: res.code };
138
158
  deps.annotations.clear(name);
139
159
  // Grade the saved flow's assertions so the agent learns immediately if it just saved a flow
140
160
  // that asserts nothing observable (passes even when the feature is broken).
141
- const loaded = await deps.flows.load(res.value.name, projectId);
161
+ const loaded = await flows.load(res.value.name, projectId);
162
+ // The absolute path, always. A save that reports success and not WHERE cost a reporter a
163
+ // `find` across their disk to discover it had landed in an unrelated repo. The name came
164
+ // back from a save that succeeded, so the guard is a type narrowing rather than a doubt.
165
+ const saved = res.value.name;
166
+ const path = isValidFlowName(saved) ? flowPath(root, saved, projectId) : undefined;
142
167
  return loaded.ok
143
- ? { ...res.value, assertions: classifyFlowAssertions(loaded.value) }
144
- : res.value;
168
+ ? {
169
+ ...res.value,
170
+ ...(path === undefined ? {} : { path }),
171
+ assertions: classifyFlowAssertions(loaded.value),
172
+ }
173
+ : { ...res.value, ...(path === undefined ? {} : { path }) };
145
174
  });
146
175
  },
147
176
  },
@@ -292,7 +321,7 @@ export const FLOW_TOOLS = [
292
321
  unverifiable: z
293
322
  .object({ reason: z.string() })
294
323
  .optional()
295
- .describe('Set on an ok replay that cannot fail: the flow asserts no observable consequence, so it passes even when the feature is broken. Same reason reticle_flow_verify reports.'),
324
+ .describe('Set on an ok replay that cannot fail: the flow asserts no observable consequence, so it passes even when the feature is broken. Same reason reticle_verify{action:"flows"} reports.'),
296
325
  decision: z
297
326
  .object({
298
327
  verdict: z.string(),
@@ -25,6 +25,14 @@ export function predicateToExpect(predicate) {
25
25
  return 0 === Object.keys(net).length ? undefined : { net };
26
26
  }
27
27
  case PredicateKind.CONSOLE: {
28
+ // `contains` has no representation in FlowExpect.console (core keeps `level` and `absent`
29
+ // and nothing else). Copying the rest through anyway would record an assertion the agent
30
+ // never made: {console, level:'warn', contains:'no-op', absent:true} would save "no warn
31
+ // entries at all", a false red on any unrelated warning, and in the presence direction it
32
+ // saves a strictly weaker claim than the one chosen. This file's own rule applies: record
33
+ // nothing rather than something different, so the flow stays assertion-free there instead.
34
+ if (predicate.contains !== undefined)
35
+ return undefined;
28
36
  const console_ = {};
29
37
  if (predicate.level !== undefined)
30
38
  console_.level = predicate.level;
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { sessionRoot } from '../project/session-root.js';
2
3
  import { verdictForSuite } from './verify-change-verdict.js';
3
4
  import { attributedFailures } from './attributed-failure.js';
4
5
  import { Verified } from '@reticlehq/core';
@@ -62,7 +63,10 @@ export const VERIFY_CHANGE_TOOLS = [
62
63
  : [];
63
64
  const since = asString(args['since']);
64
65
  const changedFiles = await resolveChangedFiles(files, since);
65
- const flows = await loadNamedFlows(deps.fs, deps.reticleRoot);
66
+ // Same root flow_save wrote to. A read that resolved differently would report "no flows
67
+ // covered this change" over flows that exist, which reads as a clean result rather than
68
+ // an error, so the disagreement would be invisible.
69
+ const flows = await loadNamedFlows(deps.fs, sessionRoot(deps, asString(args['sessionId'])));
66
70
  const { affected, unknownProvenance } = affectedSavedFlows(flows, changedFiles);
67
71
  if (0 === changedFiles.length) {
68
72
  return {
@@ -97,7 +101,7 @@ export const VERIFY_CHANGE_TOOLS = [
97
101
  const cursor = cursorBeforeReplay(deps, args);
98
102
  const verify = FLOW_TOOLS.find((tool) => tool.name === ReticleTool.FLOW_VERIFY);
99
103
  if (verify === undefined)
100
- throw new Error('reticle_flow_verify is not registered');
104
+ throw new Error('the whole-suite replay tool is not registered');
101
105
  const suite = asRecord(await verify.handler(deps, {
102
106
  names: affected,
103
107
  ...(asNumber(args['parallel']) === undefined ? {} : { parallel: args['parallel'] }),
@@ -84,3 +84,12 @@ export declare function blindSpotsFromState(state: Readonly<Record<string, numbe
84
84
  export declare function droppedByTransport(events: readonly ReticleEvent[]): number;
85
85
  /** The impeaching note for a transport gap, or undefined when the window was intact. */
86
86
  export declare function transportGapNote(events: readonly ReticleEvent[]): string | undefined;
87
+ /**
88
+ * Is the state channel dark?
89
+ *
90
+ * Nothing subscribed means NO state change is observed, so an empty `stateDiffs` is a statement
91
+ * about Reticle rather than about the app. Derived here, once, because two callers now need it —
92
+ * the act path's causal summary and the instrumentation-gap surface — and a second spelling of this
93
+ * predicate would eventually disagree with the first about what "unwatched" means.
94
+ */
95
+ export declare function isStateUnwatched(spots: readonly BlindSpot[]): boolean;
@@ -45,10 +45,12 @@ const LABEL = {
45
45
  // nothing here can support. Names the one-line fix, because the reader is the one who can apply it.
46
46
  [BlindSpotKind.UNWATCHED_STATE]: () => 'no subscribable store is registered, so NO state change is observed — an empty stateDiffs here means unwatched, not unchanged (register the store itself in your reticle-dev module: registerStore(name, store), not a getter)',
47
47
  [BlindSpotKind.WRAPPED_NETWORK]: () => 'fetch was already wrapped before Reticle, so recorded requests may differ from what was sent',
48
- // A fact about the app's wiring, not a tally. Says what the empty network view MEANS, because an
49
- // empty one here is indistinguishable from "this app made no backend calls" — and that reading is
50
- // the false green: every IPC call went unseen and every `assert { net }` over them is vacuous.
51
- [BlindSpotKind.UNOBSERVED_IPC]: () => "this Electron renderer has no Reticle preload, so NO ipcRenderer.invoke is observed — an empty network view here means unseen, not none (add require('@reticlehq/electron/preload') to your preload)",
48
+ // A fact about the app's wiring, not a tally. Says what a network view WITHOUT ipc:// records
49
+ // MEANS, because that state here is indistinguishable from "this app made no backend calls" —
50
+ // and that reading is the false green: every IPC call went unseen and every `assert { net }` over
51
+ // them is vacuous. Document-initiated loads ARE seen since subresource observation (#447), so
52
+ // the note names the missing half instead of claiming the whole view is blind.
53
+ [BlindSpotKind.UNOBSERVED_IPC]: () => "this Electron renderer has no Reticle preload, so NO ipcRenderer.invoke is observed; document-initiated loads may still appear, so a view with no ipc:// records means unseen IPC, not none (add require('@reticlehq/electron/preload') to your preload)",
52
54
  };
53
55
  /**
54
56
  * Blind spots that IMPEACH the capture, as opposed to bounding what it covers.
@@ -150,3 +152,14 @@ export function transportGapNote(events) {
150
152
  ? undefined
151
153
  : `the browser dropped ${String(dropped)} event(s) in this window (transport queue overflow), so events that would have contradicted this may never have arrived`;
152
154
  }
155
+ /**
156
+ * Is the state channel dark?
157
+ *
158
+ * Nothing subscribed means NO state change is observed, so an empty `stateDiffs` is a statement
159
+ * about Reticle rather than about the app. Derived here, once, because two callers now need it —
160
+ * the act path's causal summary and the instrumentation-gap surface — and a second spelling of this
161
+ * predicate would eventually disagree with the first about what "unwatched" means.
162
+ */
163
+ export function isStateUnwatched(spots) {
164
+ return spots.some((spot) => spot.kind === BlindSpotKind.UNWATCHED_STATE && spot.count > 0);
165
+ }
@@ -0,0 +1,52 @@
1
+ import { type InstrumentationGap } from '@reticlehq/core';
2
+ /**
3
+ * What this session still cannot see, accumulated across its verdicts.
4
+ *
5
+ * A gap reported on one verdict and then forgotten is a note. A gap that is still standing when the
6
+ * agent asks "am I done?" is a piece of work — and that difference is the entire discipline
7
+ * mechanism, because an agent finishing a task is the one moment it will act on a cost it can close.
8
+ *
9
+ * ## Why the LAST verdict decides, not the whole history
10
+ *
11
+ * The honest question is "is this app still unverifiable in this way", not "was it ever". An agent
12
+ * that hits a gap, adds the build plugin and re-verifies has CLOSED it — the later verdict simply
13
+ * stops emitting that kind — and a ledger that still reported it would punish exactly the behaviour
14
+ * this exists to cause. Nobody instruments an app twice for a tool that never notices the first time.
15
+ *
16
+ * So `open()` reflects the most recent verdict, and `everSeen` is kept separately for the summary,
17
+ * where "you hit this and fixed it" is worth saying and is not a reason to withhold done.
18
+ */
19
+ export declare class GapLedger {
20
+ #private;
21
+ /**
22
+ * Record what the verdict just reported. An empty list is a real observation — it is how a gap
23
+ * gets closed — so it must not be ignored as "nothing to record".
24
+ */
25
+ note(gaps: readonly InstrumentationGap[]): void;
26
+ /** Gaps the most recent verdict still reported. The ones that are work right now. */
27
+ open(): readonly InstrumentationGap[];
28
+ /** Every kind seen in this session, including ones since closed. */
29
+ get everSeen(): readonly string[];
30
+ /**
31
+ * Did this session leave the app less verifiable than it could be?
32
+ *
33
+ * The one question a "have I finished" call needs answered, and the one a session summary turns
34
+ * into `unproven` rather than `verified`.
35
+ */
36
+ get hasOpen(): boolean;
37
+ }
38
+ /**
39
+ * Record a verdict's gaps against the session that produced it.
40
+ *
41
+ * Tolerates a session with no ledger, and that is a statement about the seam rather than a hedge:
42
+ * `Session` is satisfied structurally, and dozens of specs — plus any consumer embedding this engine
43
+ * — build one as an object literal rather than through the constructor. Such a session is a real
44
+ * caller, it simply keeps no ledger, and recording is a side effect that must never be the reason a
45
+ * verdict fails to return.
46
+ *
47
+ * One helper rather than an optional-chain at each call site: the tolerance is a decision, and a
48
+ * decision spelled `?.` in two places is one nobody can find later.
49
+ */
50
+ export declare function noteSessionGaps(session: {
51
+ gaps?: GapLedger;
52
+ }, gaps: readonly InstrumentationGap[]): void;
@@ -0,0 +1,63 @@
1
+ import { dedupeGaps } from '@reticlehq/core';
2
+ /**
3
+ * What this session still cannot see, accumulated across its verdicts.
4
+ *
5
+ * A gap reported on one verdict and then forgotten is a note. A gap that is still standing when the
6
+ * agent asks "am I done?" is a piece of work — and that difference is the entire discipline
7
+ * mechanism, because an agent finishing a task is the one moment it will act on a cost it can close.
8
+ *
9
+ * ## Why the LAST verdict decides, not the whole history
10
+ *
11
+ * The honest question is "is this app still unverifiable in this way", not "was it ever". An agent
12
+ * that hits a gap, adds the build plugin and re-verifies has CLOSED it — the later verdict simply
13
+ * stops emitting that kind — and a ledger that still reported it would punish exactly the behaviour
14
+ * this exists to cause. Nobody instruments an app twice for a tool that never notices the first time.
15
+ *
16
+ * So `open()` reflects the most recent verdict, and `everSeen` is kept separately for the summary,
17
+ * where "you hit this and fixed it" is worth saying and is not a reason to withhold done.
18
+ */
19
+ export class GapLedger {
20
+ #open = [];
21
+ #everSeen = new Set();
22
+ /**
23
+ * Record what the verdict just reported. An empty list is a real observation — it is how a gap
24
+ * gets closed — so it must not be ignored as "nothing to record".
25
+ */
26
+ note(gaps) {
27
+ this.#open = dedupeGaps(gaps);
28
+ for (const gap of gaps)
29
+ this.#everSeen.add(gap.kind);
30
+ }
31
+ /** Gaps the most recent verdict still reported. The ones that are work right now. */
32
+ open() {
33
+ return this.#open;
34
+ }
35
+ /** Every kind seen in this session, including ones since closed. */
36
+ get everSeen() {
37
+ return [...this.#everSeen];
38
+ }
39
+ /**
40
+ * Did this session leave the app less verifiable than it could be?
41
+ *
42
+ * The one question a "have I finished" call needs answered, and the one a session summary turns
43
+ * into `unproven` rather than `verified`.
44
+ */
45
+ get hasOpen() {
46
+ return this.#open.length > 0;
47
+ }
48
+ }
49
+ /**
50
+ * Record a verdict's gaps against the session that produced it.
51
+ *
52
+ * Tolerates a session with no ledger, and that is a statement about the seam rather than a hedge:
53
+ * `Session` is satisfied structurally, and dozens of specs — plus any consumer embedding this engine
54
+ * — build one as an object literal rather than through the constructor. Such a session is a real
55
+ * caller, it simply keeps no ledger, and recording is a side effect that must never be the reason a
56
+ * verdict fails to return.
57
+ *
58
+ * One helper rather than an optional-chain at each call site: the tolerance is a decision, and a
59
+ * decision spelled `?.` in two places is one nobody can find later.
60
+ */
61
+ export function noteSessionGaps(session, gaps) {
62
+ session.gaps?.note(gaps);
63
+ }
@@ -0,0 +1,55 @@
1
+ import { type InstrumentationGap } from '@reticlehq/core';
2
+ /**
3
+ * The gaps ONE action revealed, derived from facts the act path already holds.
4
+ *
5
+ * Pure, and deliberately fed by primitives rather than by the action result: every fact below is
6
+ * already computed for other reasons — `actedSource` for the red-verdict file:line, `stateUnwatched`
7
+ * because an empty `stateDiffs` would otherwise read as "the app changed nothing", the signal count
8
+ * and route movement for the causal summary. Nothing here costs a new observation. The gap surface
9
+ * is a second reading of evidence Reticle was already collecting and then discarding.
10
+ *
11
+ * ## Each kind carries its OWN gate
12
+ *
13
+ * The rule is that a gap fires only when the verdict came back weaker because of it — and "weaker"
14
+ * is different per kind, so a single global flag would be a lie in both directions:
15
+ *
16
+ * - a missing source mapping costs nothing on a PASS. The act path already says so in as many
17
+ * words: on green nobody needs the file:line and it is noise. It costs a great deal on a red,
18
+ * where it is the first thing the agent wants;
19
+ * - a missing store costs nothing unless the caller ASKED about state;
20
+ * - a silent mutation costs nothing if the app proved the outcome some other way.
21
+ *
22
+ * Encoding the gates per kind is what keeps this from degenerating into a linter that fires on every
23
+ * uninstrumented control on the page. That surface would be ignored within a day, and it would take
24
+ * the true positives with it.
25
+ */
26
+ export interface ActionInstrumentationFacts {
27
+ /** Did the verdict pass? Several gates turn on this, in different directions. */
28
+ pass: boolean;
29
+ /** True when the outcome was positively PROVED rather than inferred. */
30
+ proved?: boolean;
31
+ /**
32
+ * Does Reticle know a `file:line` for what this verdict is about?
33
+ *
34
+ * A boolean rather than the source itself, deliberately: the act path holds a `{file, line}` and
35
+ * the assert path holds a formatted string it remembered from the last act. The gap does not care
36
+ * which — only whether the agent can be pointed at code — and taking the shape would make one of
37
+ * the two callers convert for no reason.
38
+ */
39
+ sourceKnown: boolean;
40
+ /** The ref that was driven, for the report. */
41
+ ref?: string | undefined;
42
+ /** Did the caller's predicate ask about registered state? */
43
+ stateAsked: boolean;
44
+ /** True when the session has no store registered to read. */
45
+ stateUnwatched: boolean;
46
+ /** Did the DOM move in this action's window? */
47
+ domMutated: boolean;
48
+ /** How many app signals fired in the window. */
49
+ signalsFired: number;
50
+ /** Did the route change in this action's window? */
51
+ routeChanged: boolean;
52
+ /** Did anything signal that route change? */
53
+ routeSignalFired: boolean;
54
+ }
55
+ export declare function gapsForAction(facts: ActionInstrumentationFacts): InstrumentationGap[];
@@ -0,0 +1,21 @@
1
+ import { InstrumentationGapKind, dedupeGaps, instrumentationGap, } from '@reticlehq/core';
2
+ export function gapsForAction(facts) {
3
+ const gaps = [];
4
+ // A red verdict names the control and cannot name the line that renders it. That is the round trip
5
+ // the agent is about to spend, and the one a build plugin removes permanently.
6
+ if (!facts.pass && !facts.sourceKnown) {
7
+ gaps.push(instrumentationGap(InstrumentationGapKind.NO_SOURCE_MAPPING, 'the control that was driven carries no source mapping', 'this verdict can name the control but not the file and line that render it, so finding the code is a separate search', { ...(facts.ref === undefined ? {} : { ref: facts.ref }) }));
8
+ }
9
+ // Asked about state, and there is no state channel to answer from.
10
+ if (facts.stateAsked && facts.stateUnwatched) {
11
+ gaps.push(instrumentationGap(InstrumentationGapKind.NO_STORE_REGISTERED, 'no store is registered, and this assertion was about state', 'the assertion could not be answered from the deterministic channel and had to fall back to what the DOM happens to show'));
12
+ }
13
+ // The app moved and said nothing, and Reticle could not prove the outcome another way.
14
+ if (facts.domMutated && 0 === facts.signalsFired && true !== facts.proved) {
15
+ gaps.push(instrumentationGap(InstrumentationGapKind.NO_SIGNAL_ON_MUTATION, 'the DOM changed and no signal fired for it', 'the outcome had to be inferred from the DOM instead of read from the app asserting its own success, which is the strongest evidence available', { ...(facts.ref === undefined ? {} : { ref: facts.ref }) }));
16
+ }
17
+ if (facts.routeChanged && !facts.routeSignalFired) {
18
+ gaps.push(instrumentationGap(InstrumentationGapKind.NO_ROUTE_SIGNAL, 'the route changed and nothing signalled it', 'route consequences cannot be asserted on this app, so a navigation can only be checked by what rendered afterwards'));
19
+ }
20
+ return dedupeGaps(gaps);
21
+ }
@@ -0,0 +1,37 @@
1
+ import type { InstrumentationGap } from '@reticlehq/core';
2
+ /**
3
+ * Of the controls this session drove, how many could Reticle fully observe?
4
+ *
5
+ * The first honest denominator this product has ever had. `reticle_verify{action:"coverage"}`
6
+ * already answers "which controls did you not drive"; this answers the question underneath it —
7
+ * *of the ones you did drive, how many could the app actually answer for?* A high untouched-count is
8
+ * work remaining for the agent. A low observability is work remaining in the APP, and no amount of
9
+ * driving closes it.
10
+ *
11
+ * ## Two things it deliberately refuses to do
12
+ *
13
+ * **It does not report a percentage for an empty run.** 0/0 rendered as 100% is the most flattering
14
+ * possible reading of no evidence at all, and a coverage number that starts perfect is one nobody
15
+ * believes the first time it drops.
16
+ *
17
+ * **It does not count app-level gaps against a control.** An unregistered store or an unsignalled
18
+ * route is missing from the application, not from a button. Attributing it to whichever control
19
+ * happened to be driven would put the number on the wrong thing and make it move for reasons the
20
+ * agent cannot act on. Those gaps are reported alongside, not divided into.
21
+ */
22
+ export interface Observability {
23
+ /** Distinct controls driven this session. */
24
+ driven: number;
25
+ /** How many of them Reticle could fully observe. */
26
+ observable: number;
27
+ /** Rounded percentage. OMITTED when nothing was driven — see above. */
28
+ percent?: number;
29
+ }
30
+ export declare function observabilityOf(drivenRefs: Iterable<string>, gaps: readonly InstrumentationGap[]): Observability;
31
+ export interface CoverageRegression {
32
+ was: number;
33
+ now: number;
34
+ }
35
+ export declare function coverageRegressed(best: {
36
+ percent: number;
37
+ } | undefined, current: Observability): CoverageRegression | undefined;
@@ -0,0 +1,40 @@
1
+ export function observabilityOf(
2
+ // Iterable, not an array: the session holds these as a Set, and making the caller spread it would
3
+ // be a conversion that exists only to satisfy a signature.
4
+ drivenRefs, gaps) {
5
+ const driven = new Set(drivenRefs);
6
+ const impaired = new Set();
7
+ for (const gap of gaps) {
8
+ // App-level gaps carry no ref, and a gap on a control this session never drove is about some
9
+ // earlier route — neither belongs in a ratio over what WAS driven.
10
+ if (gap.ref !== undefined && driven.has(gap.ref))
11
+ impaired.add(gap.ref);
12
+ }
13
+ const observable = driven.size - impaired.size;
14
+ if (0 === driven.size)
15
+ return { driven: 0, observable: 0 };
16
+ return { driven: driven.size, observable, percent: Math.round((observable / driven.size) * 100) };
17
+ }
18
+ /**
19
+ * The floor under the number, shipped with it rather than after it.
20
+ *
21
+ * A coverage figure with no downgrade check is a figure that gets gamed, and we would be the ones
22
+ * teaching agents to game it: the cheapest way to stop a gap firing is to stop asserting the thing
23
+ * that revealed it. The same reasoning already runs for flow assertions in `assertion-tiers-store`,
24
+ * where a flow that passes more weakly than it used to is a finding rather than a pass.
25
+ *
26
+ * Returns undefined — not a false — when there is nothing honest to compare. A first run has no
27
+ * best to fall from, and a run that drove almost nothing is evidence of a short session rather than
28
+ * of a regression.
29
+ */
30
+ /** Below this, a run is too small for its ratio to mean anything. */
31
+ const MIN_DRIVEN_TO_COMPARE = 3;
32
+ export function coverageRegressed(best, current) {
33
+ if (best === undefined)
34
+ return undefined;
35
+ if (current.percent === undefined)
36
+ return undefined;
37
+ if (current.driven < MIN_DRIVEN_TO_COMPARE)
38
+ return undefined;
39
+ return current.percent < best.percent ? { was: best.percent, now: current.percent } : undefined;
40
+ }
@@ -133,7 +133,23 @@ export function decideVerified(inputs) {
133
133
  // A vacuous green: nothing was actually proved. Grade NONE means no signal, no request, no state
134
134
  // change and no element was pinned — the assertion could not have failed, which makes passing it
135
135
  // evidence of nothing.
136
+ // Two different facts used to share this answer. Everything above has already ruled out a failure,
137
+ // a contradiction, a dirty capture, a pending write and an unread one — so when the window ALSO
138
+ // settled, the engine did not fail to see: it saw the whole window, ran every oracle, and found
139
+ // nothing wrong. That is `no-fault`, and telling it apart from "I could not see" is the difference
140
+ // between "assert something" and "look again with better coverage".
141
+ //
142
+ // Gated on `true === settled` rather than on a caller's say-so. A call that returned before the app
143
+ // stopped moving has not earned this, whatever it declared — which is what stops `no-fault` from
144
+ // becoming the green-forever button an always-available "nothing was wrong" would be.
136
145
  if (honesty.grade === HonestyGrade.NONE) {
146
+ if (true === settled) {
147
+ return {
148
+ verified: Verified.NO_FAULT,
149
+ verifiedReason: VerifiedReason.NOTHING_DECLARED,
150
+ because: 'the page settled and no channel reported a problem, but nothing was declared to prove — this is not verification. Assert a consequence the action CHANGES (a signal, a request, a route, or store state) to get one',
151
+ };
152
+ }
137
153
  return {
138
154
  verified: Verified.UNKNOWN,
139
155
  verifiedReason: VerifiedReason.VACUOUS_GRADE,
package/dist/index.d.ts CHANGED
@@ -28,6 +28,24 @@ export { VisualStore } from './visual/visual-store.js';
28
28
  export { diffPng } from './visual/visual-diff.js';
29
29
  export type { VisualDiffResult, VisualRect, DiffOptions } from './visual/visual-diff.js';
30
30
  export { crawl } from './crawl/crawl.js';
31
+ /**
32
+ * The contradiction pass, and the seam a consumer adds its own rules through.
33
+ *
34
+ * Exported because a service embedding this engine grades a recording the same way the tools do, and
35
+ * without these it would have to re-implement the fold — a second implementation of the one thing
36
+ * this product is judged on, and the one nobody dogfoods is the one that rots.
37
+ */
38
+ export { findContradictions } from './events/contradictions.js';
39
+ /**
40
+ * The MCP server factory, so a consumer can serve the tool surface it composed.
41
+ *
42
+ * Without this the composition seam is unreachable from outside the package: a consumer can build the
43
+ * list and has nothing to hand it to.
44
+ */
45
+ export { createMcpServer } from './mcp/mcp.js';
46
+ export type { Contradiction, ContradictionOptions } from './events/contradictions.js';
47
+ export { registerContradictionFold, registeredContradictionFolds, } from './events/contradiction-folds.js';
48
+ export type { ContradictionFold } from './events/contradiction-folds.js';
31
49
  export { MCP_SSE_PATH, MCP_MESSAGE_PATH } from '@reticlehq/core';
32
50
  export { BrowserPool, DEFAULT_LEASE_TTL_MS } from './pool/browser-pool.js';
33
51
  export type { Lease, Launcher, PooledBrowser } from './pool/browser-pool.js';
@@ -133,7 +151,6 @@ export interface RunningServer {
133
151
  close: () => Promise<void>;
134
152
  }
135
153
  export { resolveBridgeSecurity } from './bridge/bridge-security.js';
136
- /** Start the Reticle bridge (browser WS endpoint) and, by default, the MCP stdio server. */
137
154
  export declare function start(options?: StartOptions): Promise<RunningServer>;
138
155
  /**
139
156
  * Start the Reticle bridge in daemon mode: a single HTTP server handles both the WebSocket