@reticlehq/server 2.11.0 → 2.12.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 (252) hide show
  1. package/SKILL.md +26 -2
  2. package/dist/bridge/bridge.d.ts +0 -13
  3. package/dist/bridge/bridge.js +1 -1
  4. package/dist/bridge/bridge.test-harness.d.ts +2 -0
  5. package/dist/bridge/bridge.test-harness.js +8 -1
  6. package/dist/capsule/capsule-store.d.ts +2 -1
  7. package/dist/capsule/capsule-store.js +1 -1
  8. package/dist/capsule/causal-summary.d.ts +2 -2
  9. package/dist/chromium-launch-options.d.ts +28 -0
  10. package/dist/chromium-launch-options.js +29 -0
  11. package/dist/cli/cli-doctor.js +8 -0
  12. package/dist/cli/cli-flow-commands.d.ts +3 -1
  13. package/dist/cli/cli-flow-commands.js +34 -4
  14. package/dist/cli/cli-kill.d.ts +2 -1
  15. package/dist/cli/cli-parse.d.ts +3 -1
  16. package/dist/cli/cli-parse.js +29 -7
  17. package/dist/cli/cli-update-commands.js +20 -1
  18. package/dist/cli/cli-verify.d.ts +1 -1
  19. package/dist/cli/cli-verify.js +2 -2
  20. package/dist/cli/cloud-cli.js +12 -6
  21. package/dist/cli/config-discovery.d.ts +2 -1
  22. package/dist/cli/doctor-rows.d.ts +3 -1
  23. package/dist/cli/doctor-rows.js +3 -1
  24. package/dist/cli/gate-exit.d.ts +21 -0
  25. package/dist/cli/gate-exit.js +21 -0
  26. package/dist/cli/gate-hook-message.d.ts +33 -0
  27. package/dist/cli/gate-hook-message.js +46 -0
  28. package/dist/cli/mcp-command.d.ts +25 -0
  29. package/dist/cli/mcp-command.js +143 -0
  30. package/dist/cli/sibling-ports.d.ts +37 -0
  31. package/dist/cli/sibling-ports.js +56 -0
  32. package/dist/cli.js +37 -110
  33. package/dist/cloud/cloud-config.d.ts +2 -1
  34. package/dist/cloud/cloud-sync.d.ts +11 -0
  35. package/dist/cloud/cloud-sync.js +32 -0
  36. package/dist/crawl/crawl.d.ts +9 -13
  37. package/dist/crawl/crawl.js +20 -3
  38. package/dist/daemon/daemon-resolve.d.ts +79 -0
  39. package/dist/daemon/daemon-resolve.js +118 -0
  40. package/dist/daemon/free-port.d.ts +29 -0
  41. package/dist/daemon/free-port.js +55 -0
  42. package/dist/daemon/heartbeat.d.ts +0 -4
  43. package/dist/daemon/heartbeat.js +2 -2
  44. package/dist/dev/stale-issue-guard.d.ts +0 -2
  45. package/dist/dev/stale-issue-guard.js +1 -1
  46. package/dist/events/contradictions.d.ts +41 -0
  47. package/dist/events/contradictions.js +125 -12
  48. package/dist/events/declared.d.ts +14 -0
  49. package/dist/events/declared.js +33 -0
  50. package/dist/events/echo-mismatch.js +14 -0
  51. package/dist/events/observed-in-window.d.ts +12 -0
  52. package/dist/events/observed-in-window.js +17 -0
  53. package/dist/events/predicate-asks.d.ts +13 -0
  54. package/dist/events/predicate-asks.js +22 -0
  55. package/dist/events/predicate-eval.js +64 -22
  56. package/dist/events/predicate-parse.d.ts +0 -6
  57. package/dist/events/predicate-parse.js +43 -3
  58. package/dist/events/predicate-schema.d.ts +44 -0
  59. package/dist/events/predicate-schema.js +126 -23
  60. package/dist/events/predicate.d.ts +15 -0
  61. package/dist/events/predicate.js +41 -2
  62. package/dist/flows/coverage.d.ts +0 -8
  63. package/dist/flows/coverage.js +1 -1
  64. package/dist/flows/decision.d.ts +5 -1
  65. package/dist/flows/decision.js +33 -7
  66. package/dist/flows/flake.d.ts +0 -2
  67. package/dist/flows/flake.js +1 -1
  68. package/dist/flows/flow-intent.d.ts +52 -0
  69. package/dist/flows/flow-intent.js +123 -0
  70. package/dist/flows/flow-replay-run.js +27 -4
  71. package/dist/flows/flow-success.js +15 -3
  72. package/dist/flows/flow-tools.js +32 -4
  73. package/dist/flows/flows.d.ts +6 -1
  74. package/dist/flows/flows.js +34 -22
  75. package/dist/flows/predicate-to-expect.js +6 -3
  76. package/dist/flows/server-verify.js +16 -2
  77. package/dist/flows/verify-change-tools.js +4 -1
  78. package/dist/honesty/blind-spots.d.ts +0 -17
  79. package/dist/honesty/blind-spots.js +1 -1
  80. package/dist/honesty/feature-capture.d.ts +150 -0
  81. package/dist/honesty/feature-capture.js +233 -0
  82. package/dist/honesty/instrumentation-gaps.d.ts +30 -6
  83. package/dist/honesty/instrumentation-gaps.js +61 -5
  84. package/dist/honesty/tool-hit-rate.d.ts +86 -0
  85. package/dist/honesty/tool-hit-rate.js +90 -0
  86. package/dist/honesty/undeclared-change.d.ts +51 -0
  87. package/dist/honesty/undeclared-change.js +56 -0
  88. package/dist/honesty/verified.d.ts +15 -0
  89. package/dist/honesty/verified.js +68 -4
  90. package/dist/http-server.d.ts +0 -1
  91. package/dist/http-server.js +1 -1
  92. package/dist/hunt/hunt-report.d.ts +0 -2
  93. package/dist/hunt/hunt-report.js +1 -1
  94. package/dist/impact/impact-store.d.ts +0 -5
  95. package/dist/impact/impact-store.js +1 -1
  96. package/dist/index.d.ts +4 -1
  97. package/dist/init/agent-rules.d.ts +17 -0
  98. package/dist/init/agent-rules.js +31 -1
  99. package/dist/init/capabilities.d.ts +0 -2
  100. package/dist/init/capabilities.js +1 -1
  101. package/dist/init/closing-hint.d.ts +36 -0
  102. package/dist/init/closing-hint.js +120 -0
  103. package/dist/init/confirm.d.ts +0 -25
  104. package/dist/init/confirm.js +3 -3
  105. package/dist/init/cra.d.ts +14 -1
  106. package/dist/init/cra.js +43 -12
  107. package/dist/init/dev-script.d.ts +48 -0
  108. package/dist/init/dev-script.js +63 -0
  109. package/dist/init/mcp.d.ts +16 -5
  110. package/dist/init/mcp.js +26 -10
  111. package/dist/init/plan-framework.d.ts +0 -5
  112. package/dist/init/plan-framework.js +83 -9
  113. package/dist/init/plan.d.ts +10 -30
  114. package/dist/init/plan.js +30 -15
  115. package/dist/init/refresh-rules.d.ts +39 -0
  116. package/dist/init/refresh-rules.js +51 -0
  117. package/dist/init/run.d.ts +0 -26
  118. package/dist/init/run.js +14 -87
  119. package/dist/init/snippets.js +53 -10
  120. package/dist/input/real-input.d.ts +2 -0
  121. package/dist/input/real-input.js +3 -2
  122. package/dist/input/scroll-find.d.ts +7 -4
  123. package/dist/input/scroll-find.js +63 -25
  124. package/dist/intent/inline-intent.d.ts +46 -0
  125. package/dist/intent/inline-intent.js +103 -0
  126. package/dist/intent/intent-store.d.ts +37 -0
  127. package/dist/intent/intent-store.js +112 -0
  128. package/dist/intent/intent-tools.d.ts +2 -0
  129. package/dist/intent/intent-tools.js +80 -0
  130. package/dist/intent/open-intents.d.ts +21 -0
  131. package/dist/intent/open-intents.js +25 -0
  132. package/dist/journal/envelope.d.ts +26 -26
  133. package/dist/journal/journal-recorder.d.ts +23 -0
  134. package/dist/journal/journal-recorder.js +35 -0
  135. package/dist/mcp/attach-memory.d.ts +67 -0
  136. package/dist/mcp/attach-memory.js +142 -0
  137. package/dist/mcp/mcp-proxy.d.ts +0 -7
  138. package/dist/mcp/mcp-proxy.js +14 -2
  139. package/dist/mcp/mcp.d.ts +2 -0
  140. package/dist/mcp/mcp.js +45 -23
  141. package/dist/mcp/server-instructions.js +45 -3
  142. package/dist/mcp/tool-catalog-cache.d.ts +8 -0
  143. package/dist/mcp/tool-catalog-cache.js +10 -0
  144. package/dist/oracles/flow-instrument-gaps.d.ts +4 -4
  145. package/dist/oracles/flow-instrument-gaps.js +4 -5
  146. package/dist/oracles/self-instrument.d.ts +9 -9
  147. package/dist/oracles/self-instrument.js +19 -13
  148. package/dist/pool/browser-pool.d.ts +12 -1
  149. package/dist/pool/browser-pool.js +31 -1
  150. package/dist/pool/playwright-launcher.js +3 -1
  151. package/dist/project/fs-port.d.ts +3 -0
  152. package/dist/project/fs-port.js +3 -2
  153. package/dist/project/memory-fs.js +6 -1
  154. package/dist/project/project-tools.js +3 -32
  155. package/dist/project/reticle-dir.d.ts +2 -0
  156. package/dist/project/reticle-dir.js +1 -0
  157. package/dist/runs/context-tools.d.ts +2 -0
  158. package/dist/runs/context-tools.js +89 -0
  159. package/dist/runs/run-context.d.ts +40 -0
  160. package/dist/runs/run-context.js +158 -0
  161. package/dist/runs/run-diff.d.ts +2 -1
  162. package/dist/runs/verification-sync.js +2 -2
  163. package/dist/session/dev-server-probe.d.ts +0 -8
  164. package/dist/session/dev-server-probe.js +1 -1
  165. package/dist/session/last-act.d.ts +7 -1
  166. package/dist/session/last-act.js +2 -2
  167. package/dist/session/live-control-tools.js +1 -42
  168. package/dist/session/no-session-diagnosis.d.ts +10 -0
  169. package/dist/session/no-session-diagnosis.js +24 -7
  170. package/dist/session/no-session-next-action.d.ts +5 -0
  171. package/dist/session/no-session-next-action.js +35 -3
  172. package/dist/session/no-session-watch.d.ts +8 -0
  173. package/dist/session/no-session-watch.js +82 -64
  174. package/dist/session/output-budget.js +4 -1
  175. package/dist/session/session-health.d.ts +9 -0
  176. package/dist/session/session-health.js +23 -0
  177. package/dist/session/session-manager.js +50 -1
  178. package/dist/session/session-successor.d.ts +37 -0
  179. package/dist/session/session-successor.js +78 -0
  180. package/dist/session/session.d.ts +31 -1
  181. package/dist/session/session.js +75 -2
  182. package/dist/telemetry/automation-hint.d.ts +2 -1
  183. package/dist/telemetry/feedback-outbox.d.ts +2 -1
  184. package/dist/telemetry/project-profile.d.ts +0 -8
  185. package/dist/telemetry/project-profile.js +1 -1
  186. package/dist/telemetry/run-telemetry.d.ts +0 -2
  187. package/dist/telemetry/run-telemetry.js +1 -1
  188. package/dist/telemetry/verification-of.js +2 -2
  189. package/dist/tools/act-tools.js +126 -21
  190. package/dist/tools/assert-source.d.ts +22 -0
  191. package/dist/tools/assert-source.js +53 -0
  192. package/dist/tools/assert-verdict.d.ts +20 -3
  193. package/dist/tools/assert-verdict.js +36 -4
  194. package/dist/tools/browser-tools.js +0 -20
  195. package/dist/tools/complete-snapshot.d.ts +48 -0
  196. package/dist/tools/complete-snapshot.js +75 -0
  197. package/dist/tools/coverage-tools.d.ts +10 -1
  198. package/dist/tools/coverage-tools.js +123 -69
  199. package/dist/tools/dynamic-tools.js +29 -8
  200. package/dist/tools/error-recovery.d.ts +24 -14
  201. package/dist/tools/error-recovery.js +70 -9
  202. package/dist/tools/feedback-tools.d.ts +1 -1
  203. package/dist/tools/feedback-tools.js +2 -2
  204. package/dist/tools/invoke-tool.js +62 -2
  205. package/dist/tools/lease-hint.d.ts +10 -0
  206. package/dist/tools/lease-hint.js +53 -4
  207. package/dist/tools/lease-tools.d.ts +5 -1
  208. package/dist/tools/lease-tools.js +22 -8
  209. package/dist/tools/navigate-arrival.d.ts +0 -2
  210. package/dist/tools/navigate-arrival.js +1 -1
  211. package/dist/tools/observe-tools.js +63 -26
  212. package/dist/tools/read-tools.js +5 -3
  213. package/dist/tools/real-input-attempt.d.ts +22 -0
  214. package/dist/tools/real-input-attempt.js +153 -1
  215. package/dist/tools/reconcile-tools.js +18 -4
  216. package/dist/tools/resolve-target.d.ts +0 -7
  217. package/dist/tools/sdk-marker-probe.d.ts +29 -0
  218. package/dist/tools/sdk-marker-probe.js +57 -0
  219. package/dist/tools/snapshot-delta.d.ts +1 -1
  220. package/dist/tools/tool-kit.d.ts +12 -0
  221. package/dist/tools/tool-kit.js +16 -0
  222. package/dist/tools/tool-names.d.ts +3 -0
  223. package/dist/tools/tool-names.js +3 -0
  224. package/dist/tools/tool-surface.d.ts +85 -6
  225. package/dist/tools/tool-surface.js +122 -2
  226. package/dist/tools/tools.js +22 -4
  227. package/dist/tools/unadvertised-help.js +4 -4
  228. package/dist/tools/verify-next-baton.d.ts +53 -0
  229. package/dist/tools/verify-next-baton.js +52 -0
  230. package/dist/update/update-nudge.d.ts +7 -10
  231. package/dist/update/update-nudge.js +12 -3
  232. package/dist/update/updater.d.ts +1 -1
  233. package/docs/README.md +2 -1
  234. package/docs/cli/cloud.mdx +1 -1
  235. package/docs/cli/doctor.mdx +1 -0
  236. package/docs/cli/verify.mdx +4 -5
  237. package/docs/desktop.mdx +1 -1
  238. package/docs/docs.json +2 -2
  239. package/docs/gates.md +1 -1
  240. package/docs/install-manual.mdx +1 -1
  241. package/docs/packages/browser.mdx +2 -2
  242. package/docs/packages/server.mdx +1 -1
  243. package/docs/packages.mdx +8 -6
  244. package/docs/platform-integration.md +17 -0
  245. package/docs/predicates.mdx +8 -0
  246. package/docs/reticle-manual.md +106 -0
  247. package/docs/tools-tools-and-run.mdx +2 -4
  248. package/docs/troubleshooting.mdx +9 -0
  249. package/docs/usage.md +3 -2
  250. package/package.json +2 -2
  251. package/dist/session/session-readiness.d.ts +0 -34
  252. package/dist/session/session-readiness.js +0 -36
@@ -3,6 +3,10 @@ import { asString } from '../tools/tools-helpers.js';
3
3
  import { replayFlow } from './flow-replay.js';
4
4
  import { assertSuccess, dynamicTestids, successLabel, SUCCESS_STEP_TOOL } from './flow-success.js';
5
5
  import { buildDecision, unverifiableReason } from './decision.js';
6
+ import { classifyFlowAssertions } from './flow-classify.js';
7
+ import { dischargeFlowIntent, flowIntentStatement, flowReplayVerdictId } from './flow-intent.js';
8
+ import { IntentStore } from '../intent/intent-store.js';
9
+ import { sessionRoot } from '../project/session-root.js';
6
10
  import { waitForPredicate } from '../events/predicate.js';
7
11
  import { computeSegments } from '../journal/rollups.js';
8
12
  import { AssertionTiersStore } from './assertion-tiers-store.js';
@@ -10,7 +14,7 @@ import { toFlowSources } from './flow-sources.js';
10
14
  import { reportAndAccumulate } from '../journal/deviation-service.js';
11
15
  import { EnvelopeStore } from '../journal/envelope-store.js';
12
16
  import { homedir } from 'node:os';
13
- import { syncRunRecordToCloud, SyncOutcome } from '../cloud/cloud-sync.js';
17
+ import { cloudFetch, syncRunRecordToCloud, SyncOutcome } from '../cloud/cloud-sync.js';
14
18
  import { resolveProjectCloud } from '../cloud/cloud-config.js';
15
19
  import { log } from '../log.js';
16
20
  export function latestRecordedFlow(events) {
@@ -66,7 +70,7 @@ async function recordReplayRun(deps, name, status, driftSteps, durationMs, proje
66
70
  const cloud = await resolveProjectCloud(deps.fs, deps.reticleRoot, homedir(), process.env);
67
71
  if (null === cloud.config || !cloud.policy.memory)
68
72
  return; // not attached / memory disabled → local only
69
- const result = await syncRunRecordToCloud({ kind: RunKind.FLOW_REPLAY, name, status: runStatus, at: deps.now(), durationMs }, projectId, cloud.config, (url, init) => fetch(url, init));
73
+ const result = await syncRunRecordToCloud({ kind: RunKind.FLOW_REPLAY, name, status: runStatus, at: deps.now(), durationMs }, projectId, cloud.config, cloudFetch);
70
74
  if (result.outcome !== SyncOutcome.SYNCED) {
71
75
  log('cloud-run-record-sync-failed', { flow: name, status: result.status, error: result.error });
72
76
  }
@@ -120,6 +124,13 @@ export async function replayNamedFlow(deps, args) {
120
124
  error: { code: loaded.code, message: flowErrorMessage(loaded.code) },
121
125
  };
122
126
  }
127
+ // What this flow is FOR, from the shared ledger — so a failure can report the business outcome
128
+ // that stopped being true before the step that stopped being green. Undefined when nothing
129
+ // declared one, which the decision then says plainly rather than inventing a goal from step names.
130
+ const intents = new IntentStore(deps.fs, sessionRoot(deps, asString(args['sessionId'])), {
131
+ now: deps.now,
132
+ });
133
+ const intentSaid = await flowIntentStatement(intents, loaded.value);
123
134
  const session = deps.sessions.resolve(asString(args['sessionId']));
124
135
  // Captured before replay: if the tab isn't on the flow's start page, a step-1 drift is a wrong-page
125
136
  // symptom, not a regression — surface that on the decision instead of a bare "a step no longer matches".
@@ -156,6 +167,18 @@ export async function replayNamedFlow(deps, args) {
156
167
  })),
157
168
  // Record what this flow COVERS so a later deletion can be scoped to the files that changed.
158
169
  toFlowSources([{ name, steps: loaded.value.steps }])[0]?.sources ?? []);
170
+ // The ledger learns what this run proved. Only a flow that COULD have gone red discharges: an
171
+ // assertion-free flow is never bound, and `dischargeIntent` refuses an unbound intent, so the
172
+ // guard here is the cheap half of a rule the ledger already enforces. `grade` is what makes a
173
+ // later weakening visible — an intent re-proved by a weaker flow says so in the git diff.
174
+ if (unverifiableReason(loaded.value) === undefined) {
175
+ const provedAt = deps.now();
176
+ await dischargeFlowIntent(intents, loaded.value, {
177
+ verdictId: flowReplayVerdictId(name, provedAt),
178
+ grade: classifyFlowAssertions(loaded.value).grade,
179
+ at: provedAt,
180
+ });
181
+ }
159
182
  }
160
183
  // Push-default: the deviation report over this drive's segments, learned across runs. Best-effort.
161
184
  const deviation = await computeReplayDeviation(deps, session, replayFloor);
@@ -167,7 +190,7 @@ export async function replayNamedFlow(deps, args) {
167
190
  steps,
168
191
  error: { code: ReplayStatus.ERROR, message: failed.error ?? 'flow action failed' },
169
192
  };
170
- errored.decision = buildDecision(errored, loaded.value);
193
+ errored.decision = buildDecision(errored, loaded.value, intentSaid);
171
194
  applyStartPathHint(errored, startPathHint);
172
195
  if (deviation !== undefined)
173
196
  errored.deviation = deviation;
@@ -182,7 +205,7 @@ export async function replayNamedFlow(deps, args) {
182
205
  if (cannotFail !== undefined)
183
206
  result.unverifiable = { reason: cannotFail };
184
207
  if (status !== ReplayStatus.OK)
185
- result.decision = buildDecision(result, loaded.value);
208
+ result.decision = buildDecision(result, loaded.value, intentSaid);
186
209
  applyStartPathHint(result, startPathHint);
187
210
  if (deviation !== undefined)
188
211
  result.deviation = deviation;
@@ -38,9 +38,21 @@ export function successLabel(success) {
38
38
  export function successToPredicate(success, dynamic) {
39
39
  const parts = [];
40
40
  if (success.signal !== undefined) {
41
- parts.push(success.signalData !== undefined
42
- ? { kind: PredicateKind.SIGNAL, name: success.signal, dataMatches: success.signalData }
43
- : { kind: PredicateKind.SIGNAL, name: success.signal });
41
+ const signal = {
42
+ kind: PredicateKind.SIGNAL,
43
+ name: success.signal,
44
+ };
45
+ if (success.signalData !== undefined)
46
+ signal.dataMatches = success.signalData;
47
+ if (success.signalCount !== undefined) {
48
+ signal.count = success.signalCount;
49
+ // Same post-settle reasoning as net.count: the success waiter is wait-until-true, so an exact
50
+ // count is transiently satisfied the instant the FIRST matching signal fires — before a
51
+ // double-fire's duplicate arrives. Gating on `settled` forces the count to be read only after
52
+ // the app goes quiet, by which point the duplicate IS counted and the over-count fails.
53
+ parts.push({ kind: PredicateKind.SETTLED });
54
+ }
55
+ parts.push(signal);
44
56
  }
45
57
  if (success.net !== undefined) {
46
58
  const net = { kind: PredicateKind.NET };
@@ -6,7 +6,7 @@ import { recordSuiteFlakes } from './suite-flakes.js';
6
6
  import { ReticleTool } from '../tools/tool-names.js';
7
7
  import { asNumber, asString } from '../tools/tools-helpers.js';
8
8
  import { log } from '../log.js';
9
- import { syncFlowToCloud, SyncOutcome } from '../cloud/cloud-sync.js';
9
+ import { cloudFetch, syncFlowToCloud, SyncOutcome } from '../cloud/cloud-sync.js';
10
10
  import { mapWithConcurrency, resolveConcurrency } from './parallel-suite.js';
11
11
  import { acquireLeasedSession } from '../tools/lease-tools.js';
12
12
  import { homedir } from 'node:os';
@@ -30,7 +30,7 @@ async function syncSavedFlowToCloud(deps, flow, projectId) {
30
30
  const cloud = await resolveProjectCloud(deps.fs, deps.reticleRoot, homedir(), process.env);
31
31
  if (null === cloud.config || !cloud.policy.flows)
32
32
  return; // not attached / flows disabled → local only
33
- const result = await syncFlowToCloud(flow, cloud.config, projectId, (url, init) => fetch(url, init));
33
+ const result = await syncFlowToCloud(flow, cloud.config, projectId, cloudFetch);
34
34
  if (result.outcome !== SyncOutcome.SYNCED) {
35
35
  log('cloud-flow-sync-failed', { flow: flow.name, status: result.status, error: result.error });
36
36
  }
@@ -86,6 +86,17 @@ function flowsForSession(deps, projectId) {
86
86
  }
87
87
  return { flows: new FlowStore(deps.fs, resolved.root, { now: deps.now }), root: resolved.root };
88
88
  }
89
+ /**
90
+ * How a save reports that nobody said what the flow is for.
91
+ *
92
+ * Declared once and shared by every save tool: an undeclared field is stripped from
93
+ * `structuredContent` by a schema-strict MCP client, so a save path that forgot this line would
94
+ * report the nudge to the unit tests and to nobody else.
95
+ */
96
+ const INTENT_GAP_FIELD = z
97
+ .unknown()
98
+ .optional()
99
+ .describe('Present ONLY when the flow was saved with nothing saying what it is for: { kind, missing, cost, fix }. The flow is on disk either way — this never blocks a save. Close it by saving again with `intent`, or by setting the flow file’s `intentId` to an intent already in the ledger. Nothing here is ever derived from the flow name, the steps or the assertions: a guessed goal reads as the author’s own words.');
89
100
  export const FLOW_TOOLS = [
90
101
  {
91
102
  name: ReticleTool.FLOW_SAVE,
@@ -98,6 +109,10 @@ export const FLOW_TOOLS = [
98
109
  flowName: z
99
110
  .string()
100
111
  .describe('Name for the flow file (saved to .reticle/flows/<flowName>.json). Use again in reticle_flow{action:"load"} / reticle_flow_replay.'),
112
+ intent: z
113
+ .string()
114
+ .optional()
115
+ .describe('What this flow is FOR, in prose: which user does what, and what should become true. Answer it here rather than in a second call. Without it the flow still saves, but the day it goes red the report can only name the step that broke, not the thing that stopped being true.'),
101
116
  sessionId: z
102
117
  .string()
103
118
  .optional()
@@ -124,6 +139,7 @@ export const FLOW_TOOLS = [
124
139
  warning: z.string().optional(),
125
140
  })
126
141
  .optional(),
142
+ intentGap: INTENT_GAP_FIELD,
127
143
  error: z.string().optional(),
128
144
  code: z.string().optional(),
129
145
  },
@@ -138,7 +154,9 @@ export const FLOW_TOOLS = [
138
154
  }
139
155
  // fold any structured annotations (expect/dynamic/success/intent) onto the saved flow.
140
156
  const success = deps.annotations.success(name);
141
- const intent = deps.annotations.intent(name);
157
+ // The inline argument wins over an annotation left from an earlier call: it is the more recent
158
+ // statement, and it is the one the author just typed.
159
+ const intent = asString(args['intent']) ?? deps.annotations.intent(name);
142
160
  const annotations = {
143
161
  stepExpect: deps.annotations.stepExpect(name),
144
162
  dynamic: deps.annotations.dynamic(name),
@@ -477,6 +495,10 @@ export const FLOW_TOOLS = [
477
495
  .string()
478
496
  .optional()
479
497
  .describe('Override the flow name embedded in the recorded flow. Omit to use the recorder-assigned name.'),
498
+ intent: z
499
+ .string()
500
+ .optional()
501
+ .describe('What this flow is FOR, in prose: which user does what, and what should become true. The recorder captures clicks, not goals, so this is the only place the goal can come from. Without it the flow still saves, but the day it goes red the report can only name the step that broke.'),
480
502
  ...{
481
503
  sessionId: z
482
504
  .string()
@@ -491,6 +513,7 @@ export const FLOW_TOOLS = [
491
513
  stepCount: z.number().optional(),
492
514
  degraded: z.number().optional(),
493
515
  empty: z.boolean().optional(),
516
+ intentGap: INTENT_GAP_FIELD,
494
517
  error: z.string().optional(),
495
518
  code: z.string().optional(),
496
519
  },
@@ -504,7 +527,12 @@ export const FLOW_TOOLS = [
504
527
  };
505
528
  }
506
529
  const override = asString(aliasParam(args, 'flowName', ['flow'])['flowName']);
507
- const flow = override !== undefined ? { ...recorded.flow, name: override } : recorded.flow;
530
+ const intent = asString(args['intent']);
531
+ const flow = {
532
+ ...recorded.flow,
533
+ ...(override === undefined ? {} : { name: override }),
534
+ ...(intent === undefined ? {} : { intent }),
535
+ };
508
536
  // The store stamps the project into the file AND routes it to the per-project subdir (a shared
509
537
  // daemon serves many apps), so location and content agree from one source of truth.
510
538
  const emptyRecorded = emptyFlowRefusal(flow.steps.length, flow.name);
@@ -1,5 +1,5 @@
1
1
  import { FlowErrorCode } from '@reticlehq/core';
2
- import type { FlowAnchor, FlowExpect, FlowFile, FlowStep, HealChange } from '@reticlehq/core';
2
+ import type { FlowAnchor, FlowExpect, FlowFile, FlowStep, HealChange, InstrumentationGap } from '@reticlehq/core';
3
3
  import type { CompiledProgram, RecordedStep } from './recordings.js';
4
4
  import type { FileSystemPort } from '../project/fs-port.js';
5
5
  /** A monotonic clock injected for createdAt — never call Date.now inside the store (rule 7). */
@@ -30,6 +30,11 @@ interface SaveSummary {
30
30
  stepCount: number;
31
31
  degraded: number;
32
32
  empty: boolean;
33
+ /**
34
+ * Present only when the flow was saved with nothing saying what it is for. Never blocks the save,
35
+ * and absent the moment the flow carries prose or an `intentId`. See `flowIntentGap`.
36
+ */
37
+ intentGap?: InstrumentationGap;
33
38
  }
34
39
  /**
35
40
  * The structured annotations folded onto a flow at save time: per-step
@@ -3,6 +3,8 @@ import { AnchorKind, DEGRADED_ANCHOR_ROLE, FLOW_FILE_VERSION, FlowErrorCode, Flo
3
3
  import { ReticleTool } from '../tools/tool-names.js';
4
4
  import { asString, asRecord } from '../tools/tools-helpers.js';
5
5
  import { applyHealChanges } from './heal.js';
6
+ import { flowIntentGap, linkFlowIntent } from './flow-intent.js';
7
+ import { IntentStore } from '../intent/intent-store.js';
6
8
  import { flowDir, flowPath, reticleDirPaths, isValidFlowName } from '../project/reticle-dir.js';
7
9
  /**
8
10
  * A projectId only scopes storage when it's a safe single path segment (it's stamped from the
@@ -168,6 +170,34 @@ export class FlowStore {
168
170
  #serialize(flow) {
169
171
  return `${JSON.stringify(flow, null, JSON_INDENT)}\n`;
170
172
  }
173
+ /**
174
+ * Register the flow's business goal in the intent ledger and stamp the row's id onto the flow.
175
+ *
176
+ * Both save paths route through here — the compiled-recording one and the in-page recorder one —
177
+ * because a flow's goal must land in the ledger whichever way the flow arrived. A flow with no
178
+ * goal is returned untouched and nothing is written, so an older flow keeps its exact bytes.
179
+ */
180
+ #linkIntent(flow) {
181
+ return linkFlowIntent(new IntentStore(this.#fs, this.#root, this.#clock), flow);
182
+ }
183
+ /**
184
+ * What a caller is told about a flow that just landed on disk.
185
+ *
186
+ * Both save paths build it here rather than each assembling their own object, because the two used
187
+ * to be duplicates and the intent nudge is exactly the kind of field that gets added to one of a
188
+ * pair. A flow saved by the recorder and a flow saved from a compiled recording are the same
189
+ * regression test, and must not report differently for having arrived by a different door.
190
+ */
191
+ #summary(flow) {
192
+ const gap = flowIntentGap(flow);
193
+ return {
194
+ name: flow.name,
195
+ stepCount: flow.steps.length,
196
+ degraded: flow.steps.filter((s) => true === s.degraded).length,
197
+ empty: 0 === flow.steps.length,
198
+ ...(gap === undefined ? {} : { intentGap: gap }),
199
+ };
200
+ }
171
201
  /**
172
202
  * Convert a CompiledProgram (testid-normalized) into an anchored, on-disk flow + write it.
173
203
  * Optionally fold structured annotations (per-step expect, dynamic[], success) onto
@@ -186,19 +216,10 @@ export class FlowStore {
186
216
  createdAt: this.#clock.now(),
187
217
  steps,
188
218
  };
189
- const flow = withAnnotations(base, annotations);
219
+ const flow = await this.#linkIntent(withAnnotations(base, annotations));
190
220
  await this.#fs.mkdir(flowDir(this.#root, pid));
191
221
  await this.#fs.writeFile(flowPath(this.#root, program.name, pid), this.#serialize(flow));
192
- const degraded = flow.steps.filter((s) => true === s.degraded).length;
193
- return {
194
- ok: true,
195
- value: {
196
- name: program.name,
197
- stepCount: flow.steps.length,
198
- degraded,
199
- empty: 0 === flow.steps.length,
200
- },
201
- };
222
+ return { ok: true, value: this.#summary(flow) };
202
223
  }
203
224
  /**
204
225
  * Persist an already-anchored FlowFile captured in-page (no recompilation). The
@@ -215,19 +236,10 @@ export class FlowStore {
215
236
  const parsed = FlowFileSchema.safeParse(stamped);
216
237
  if (!parsed.success)
217
238
  return { ok: false, code: FlowErrorCode.PARSE_FAILED };
218
- const valid = parsed.data;
239
+ const valid = await this.#linkIntent(parsed.data);
219
240
  await this.#fs.mkdir(flowDir(this.#root, pid));
220
241
  await this.#fs.writeFile(flowPath(this.#root, asFlowName(valid.name), pid), this.#serialize(valid));
221
- const degraded = valid.steps.filter((s) => true === s.degraded).length;
222
- return {
223
- ok: true,
224
- value: {
225
- name: valid.name,
226
- stepCount: valid.steps.length,
227
- degraded,
228
- empty: 0 === valid.steps.length,
229
- },
230
- };
242
+ return { ok: true, value: this.#summary(valid) };
231
243
  }
232
244
  /**
233
245
  * Apply confident testid rebinds to an on-disk flow (the reticle_flow_heal
@@ -8,9 +8,12 @@ export function predicateToExpect(predicate) {
8
8
  case PredicateKind.SIGNAL: {
9
9
  if (predicate.name === undefined)
10
10
  return undefined;
11
- return predicate.dataMatches === undefined
12
- ? { signal: predicate.name }
13
- : { signal: predicate.name, signalData: predicate.dataMatches };
11
+ const signal = { signal: predicate.name };
12
+ if (predicate.dataMatches !== undefined)
13
+ signal.signalData = predicate.dataMatches;
14
+ if (predicate.count !== undefined)
15
+ signal.signalCount = predicate.count;
16
+ return signal;
14
17
  }
15
18
  case PredicateKind.NET: {
16
19
  const net = {};
@@ -1,5 +1,6 @@
1
1
  import { VerifyMode } from '../cloud/cloud-config.js';
2
- import { submitServerVerification } from '../cloud/cloud-sync.js';
2
+ import { CLOUD_VERIFY_TIMEOUT_MS, cloudFetch, submitServerVerification, } from '../cloud/cloud-sync.js';
3
+ import { log } from '../log.js';
3
4
  const PASS = 'pass';
4
5
  const UNVERIFIED = 'unverified';
5
6
  const SOURCE = 'reticle-mcp';
@@ -31,7 +32,20 @@ export async function runServerVerify(deps, cloud, sessionId, flows) {
31
32
  // The server hits the URL itself; with no URL (or a localhost one it can't reach) fall back to local.
32
33
  if (previewUrl === undefined || 0 === previewUrl.length)
33
34
  return null;
34
- const report = await submitServerVerification({ previewUrl, flows, source: SOURCE }, cloud.config, (url, init) => fetch(url, init));
35
+ // The submit blocks while the hosted runner drives a real browser, so it gets the long budget — but a
36
+ // budget it must have: this is awaited inside the verify tool, and an unbounded wait here is an MCP
37
+ // call that never returns. A timeout is reported and then falls back to the local replay below.
38
+ const report = await submitServerVerification({ previewUrl, flows, source: SOURCE }, cloud.config, async (url, init) => {
39
+ try {
40
+ return await cloudFetch(url, init, CLOUD_VERIFY_TIMEOUT_MS);
41
+ }
42
+ catch (error) {
43
+ log('cloud-server-verify-failed', {
44
+ error: error instanceof Error ? error.message : String(error),
45
+ });
46
+ throw error;
47
+ }
48
+ });
35
49
  if (null === report)
36
50
  return null;
37
51
  // The hosted runner said it couldn't actually verify (e.g. it's not enabled yet). Never surface that as
@@ -197,7 +197,10 @@ async function inspectAfterReplay(deps, args, cursor) {
197
197
  }
198
198
  try {
199
199
  const session = deps.sessions.resolve(asString(args['sessionId']));
200
- const contradictions = findContradictions(session.eventsSince(cursor));
200
+ const contradictions = findContradictions(session.eventsSince(cursor), {
201
+ currentDocumentId: session.currentDocumentId,
202
+ currentEditEpoch: session.currentEditEpoch,
203
+ });
201
204
  const snapshot = await session.command(ReticleCommand.SNAPSHOT, {
202
205
  mode: SnapshotMode.INTERACTIVE,
203
206
  });
@@ -65,23 +65,6 @@ export declare function blindSpotsFromEvents(events: readonly ReticleEvent[]): B
65
65
  * from what happened to be said recently.
66
66
  */
67
67
  export declare function blindSpotsFromState(state: Readonly<Record<string, number>>): BlindSpot[];
68
- /**
69
- * Events the BROWSER's own transport queue threw away inside this window, or 0.
70
- *
71
- * `RATE_LIMITED` — the BRIDGE sampling because events arrived faster than its per-second cap — is
72
- * already the one blind spot that `impeachesCapture`, on the reasoning that a green over a window
73
- * you did not fully see "would only describe what was observed". `TRANSPORT_OVERFLOW` is the exact
74
- * same loss on the other side of the wire, and it was read in journal rollups and NOWHERE on the
75
- * verdict path. So the identical condition downgraded a verdict from one side of the socket and was
76
- * invisible from the other: a window that dropped 34 events graded `proved`, in a sentence whose own
77
- * words were "over a clean capture".
78
- *
79
- * `TRUNCATED` is deliberately NOT counted. It names the channel it capped (a DOM-mutation flood on
80
- * any busy page) and is routine churn; treating it as lost evidence would caveat every verdict on
81
- * every real app, and a caveat that is always present is one nobody reads. TRANSPORT_OVERFLOW is the
82
- * honest "arbitrary events are gone" marker — it cannot say which.
83
- */
84
- export declare function droppedByTransport(events: readonly ReticleEvent[]): number;
85
68
  /** The impeaching note for a transport gap, or undefined when the window was intact. */
86
69
  export declare function transportGapNote(events: readonly ReticleEvent[]): string | undefined;
87
70
  /**
@@ -135,7 +135,7 @@ export function blindSpotsFromState(state) {
135
135
  * every real app, and a caveat that is always present is one nobody reads. TRANSPORT_OVERFLOW is the
136
136
  * honest "arbitrary events are gone" marker — it cannot say which.
137
137
  */
138
- export function droppedByTransport(events) {
138
+ function droppedByTransport(events) {
139
139
  let dropped = 0;
140
140
  for (const e of events) {
141
141
  if (e.type !== EventType.TRANSPORT_OVERFLOW)
@@ -0,0 +1,150 @@
1
+ import { type Intent, type JournalAction } from '@reticlehq/core';
2
+ /**
3
+ * Were `reticle_context` and `reticle_intent` used at all — and did using them change anything?
4
+ *
5
+ * Both shipped with the same pre-registered disproof: *if agents never call it, cut it*. Nothing
6
+ * recorded whether they were called, so the disproof could never be run, and a feature whose
7
+ * disproof cannot be run is one nobody can kill. This is the smallest thing that makes the question
8
+ * answerable, per session, locally — an instrument, not a scold. The numbers exist so a FEATURE can
9
+ * be removed, never so an agent can be blamed for how it drove.
10
+ *
11
+ * ## Fold first, record only what nothing records
12
+ *
13
+ * Three of the four questions are already answerable from what is on disk. The intent ledger says
14
+ * what was declared; the journal says what was driven and what verdicts were drawn. Those are folded
15
+ * here and never re-stored, for the reason `run-context.ts` gives at length: two ways to compute one
16
+ * number is worse than one way.
17
+ *
18
+ * The journal does NOT record read-only calls — it holds actions and verdicts only. So "was
19
+ * `reticle_context` called" has no ledger to fold, and that one thing is recorded, in memory, on the
20
+ * session, by `CaptureLedger`. It is the whole of the new state.
21
+ *
22
+ * ## Absent is not zero
23
+ *
24
+ * A session this daemon has recorded nothing for — journaling off, a session adopted after the fact
25
+ * — reports `observed: false` and states no counts at all. It has not established that the features
26
+ * went unused; it has established that it was not watching. This repo has shipped the other bug
27
+ * twice.
28
+ */
29
+ /**
30
+ * The calls this instrument needs and the journal does not keep.
31
+ *
32
+ * Deliberately a short explicit list rather than "everything that is not an action". Under-inclusion
33
+ * is the safe direction: a read tool missing from here lowers the hit side and the miss side by the
34
+ * same call, so the comparison the disproof turns on stays fair. Over-inclusion is not — a lifecycle
35
+ * or disk-side tool landing in here would be counted as a "read" the agent made of the app, which it
36
+ * was not.
37
+ */
38
+ export declare const CAPTURED_TOOLS: ReadonlySet<string>;
39
+ /** One recorded call: which tool, what it was about, and how far into the run it happened. */
40
+ export interface CapturedCall {
41
+ tool: string;
42
+ /** The ref or target the call named, when it named one. Absent for a whole-page read. */
43
+ subject?: string;
44
+ /**
45
+ * The session's dispatched-action count at the moment of the call — the same counter that mints
46
+ * journal action ids, so it lines up with `reticle_context`'s own `step` whenever the journal is on.
47
+ */
48
+ afterActions: number;
49
+ }
50
+ /**
51
+ * The one thing that is stored rather than folded: the read-only calls the journal never sees.
52
+ *
53
+ * In memory and per session, the same shape as `GapLedger`. It is not durable on purpose — the
54
+ * question is about one run, and a second file on disk would be a second thing to reconcile with the
55
+ * journal.
56
+ */
57
+ export declare class CaptureLedger {
58
+ #private;
59
+ /**
60
+ * One call, under the name of the tool that RAN — every tool, not just the read set above.
61
+ *
62
+ * Separate from `note` because it answers a different question and is bounded differently: this
63
+ * map cannot outgrow the tool table however long the session runs, so it needs no cap and never
64
+ * has to report a floor. See honesty/tool-hit-rate.ts for what it feeds.
65
+ */
66
+ noteTool(tool: string): void;
67
+ /** How many times each tool was called this session, in first-call order. */
68
+ toolCalls(): ReadonlyMap<string, number>;
69
+ note(call: CapturedCall): void;
70
+ calls(): readonly CapturedCall[];
71
+ /** How many calls fell off the front. Non-zero means the counts below are a floor. */
72
+ get dropped(): number;
73
+ }
74
+ /**
75
+ * Record one captured call against the session that made it.
76
+ *
77
+ * Tolerates a session that carries no ledger, exactly as `noteSessionGaps` does and for the same
78
+ * reason: `Session` is satisfied structurally, and many specs — plus any consumer embedding this
79
+ * engine — build one as an object literal. Such a session is a real caller that simply keeps no
80
+ * ledger, and an instrument must never be the reason a tool call throws. Its calls are then absent
81
+ * rather than counted as zero, which is what `observed: false` exists to say.
82
+ */
83
+ export declare function noteCapturedCall(session: {
84
+ capture?: CaptureLedger;
85
+ actionCount?: number;
86
+ }, call: Omit<CapturedCall, 'afterActions'>): void;
87
+ /**
88
+ * Count one call against the session that made it, whichever tool it was.
89
+ *
90
+ * Tolerant of a ledger-less session for exactly the reason `noteCapturedCall` is: its calls are then
91
+ * absent rather than counted as zero, which is what `observed: false` exists to say.
92
+ */
93
+ export declare function noteToolDispatched(session: {
94
+ capture?: CaptureLedger;
95
+ }, tool: string): void;
96
+ /** What one session's use of the two features looks like. Everything but `observed` is omitted when nothing was recorded. */
97
+ export interface FeatureCapture {
98
+ /** False when this daemon recorded nothing for the session — "not watched", never "not used". */
99
+ observed: boolean;
100
+ /** The ledger dropped calls, so every count is a floor. Present only when true. */
101
+ truncated?: boolean;
102
+ context?: {
103
+ calls: number;
104
+ /** The dispatched-action count each call was made at, in order. */
105
+ atSteps: number[];
106
+ acted: number;
107
+ refetched: number;
108
+ readOther: number;
109
+ nothingAfter: number;
110
+ };
111
+ intents?: {
112
+ declared: number;
113
+ open: number;
114
+ };
115
+ missed?: {
116
+ verdictsWithNoIntentDeclared: number;
117
+ refetchedEstablished: number;
118
+ };
119
+ }
120
+ /**
121
+ * Did calling `reticle_context` change what happened next?
122
+ *
123
+ * The mechanical proxy, stated so it can be argued with: look at the very next recorded call. If an
124
+ * action was dispatched before it — the action counter moved — the agent ACTED, and the context
125
+ * plausibly helped. If instead the next call is a read of a subject the context had just supplied,
126
+ * the agent re-fetched what it had been handed, and the context did not help.
127
+ *
128
+ * ## What this proxy CANNOT see
129
+ *
130
+ * - Whether the agent READ the response, reasoned over it, or wrote a better answer from it. There
131
+ * is no signal for that anywhere, and this must not be mistaken for one.
132
+ * - Anything that happens between two Reticle calls: source edits, calls to other MCP servers, a
133
+ * turn ending, a human interrupting. A long gap is invisible and reads as adjacency.
134
+ * - Reads that name no ref or target (a whole-page snapshot). They land in `readOther`, never in
135
+ * `refetched`, so a snapshot that duplicated the context is under-counted, not over-counted.
136
+ * - Causation. `acted` is proximity in a call sequence, nothing more. An agent that would have acted
137
+ * anyway is indistinguishable from one the context unblocked.
138
+ * - `reticle_context` called with no session connected — it answers there by design, and there is no
139
+ * per-session ledger to record it against, so those calls are missing entirely.
140
+ * - Two agents driving one session. Their calls interleave into one sequence and "next call" stops
141
+ * meaning "next thing that agent did".
142
+ */
143
+ export declare function foldFeatureCapture(input: {
144
+ calls: readonly CapturedCall[];
145
+ dropped: number;
146
+ actions: readonly JournalAction[];
147
+ intents: readonly Intent[];
148
+ /** The session's dispatched-action count now, so a run that ended on an action is not read as idle. */
149
+ finalActions: number;
150
+ }): FeatureCapture;