@reticlehq/server 3.2.0 → 3.3.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 (223) hide show
  1. package/SKILL.md +16 -13
  2. package/dist/command/cli/adhoc-suite.d.ts +19 -0
  3. package/dist/command/cli/adhoc-suite.js +88 -0
  4. package/dist/command/cli/adhoc-verdict.d.ts +23 -2
  5. package/dist/command/cli/adhoc-verdict.js +18 -10
  6. package/dist/command/cli/cli-doctor.js +25 -5
  7. package/dist/command/cli/cli-parse-grammar.d.ts +7 -0
  8. package/dist/command/cli/cli-parse-grammar.js +7 -0
  9. package/dist/command/cli/cli-parse-verify.d.ts +2 -5
  10. package/dist/command/cli/cli-parse-verify.js +86 -13
  11. package/dist/command/cli/cli-parse.d.ts +7 -1
  12. package/dist/command/cli/cli-parse.js +33 -5
  13. package/dist/command/cli/cli-verify.d.ts +59 -0
  14. package/dist/command/cli/cli-verify.js +129 -17
  15. package/dist/command/cli/cloud-kit.d.ts +0 -2
  16. package/dist/command/cli/cloud-kit.js +1 -1
  17. package/dist/command/cli/doctor/doctor-sessions-line.d.ts +2 -1
  18. package/dist/command/cli/launch/cli-launch.d.ts +1 -29
  19. package/dist/command/cli/launch/cli-launch.js +6 -40
  20. package/dist/command/cli/ports/resolve/cli-port.d.ts +13 -0
  21. package/dist/command/cli/ports/resolve/cli-port.js +45 -1
  22. package/dist/command/cli/report-command.d.ts +16 -0
  23. package/dist/command/cli/report-command.js +97 -0
  24. package/dist/command/cli.js +5 -0
  25. package/dist/command/daemon/binding/daemon-status-probe.d.ts +30 -0
  26. package/dist/command/daemon/binding/daemon-status-probe.js +45 -0
  27. package/dist/command/daemon/binding/port-presence.d.ts +1 -1
  28. package/dist/command/daemon/idle-shutdown.d.ts +2 -1
  29. package/dist/command/setup/init/init-host.js +8 -0
  30. package/dist/features/crawl/crawl.d.ts +2 -0
  31. package/dist/features/crawl/crawl.js +1 -0
  32. package/dist/features/harness/jev-driver.js +6 -9
  33. package/dist/features/harness/platform-config.d.ts +10 -0
  34. package/dist/features/harness/platform-config.js +8 -0
  35. package/dist/index.d.ts +1 -1
  36. package/dist/index.js +40 -95
  37. package/dist/judgement/capsule/capsule-store.d.ts +31 -2
  38. package/dist/judgement/capsule/capsule-store.js +58 -6
  39. package/dist/judgement/capsule/capsule.js +5 -0
  40. package/dist/judgement/capsule/causal-summary.d.ts +13 -1
  41. package/dist/judgement/capsule/causal-summary.js +7 -0
  42. package/dist/judgement/domain/domain-model.js +38 -9
  43. package/dist/judgement/outcome/assertion-integrity.d.ts +3 -3
  44. package/dist/judgement/phenomena/phenomena.d.ts +2 -1
  45. package/dist/judgement/runs/artifact/gap-report.d.ts +17 -0
  46. package/dist/judgement/runs/artifact/gap-report.js +46 -0
  47. package/dist/judgement/runs/artifact/gap-summary.d.ts +58 -0
  48. package/dist/judgement/runs/artifact/gap-summary.js +85 -0
  49. package/dist/judgement/runs/artifact/render-report.js +4 -1
  50. package/dist/judgement/runs/artifact/to-artifact.d.ts +7 -2
  51. package/dist/judgement/runs/artifact/to-artifact.js +1 -1
  52. package/dist/judgement/runs/context-tools.js +21 -3
  53. package/dist/judgement/runs/drive-run.js +7 -2
  54. package/dist/language/flows/annotate-notes/annotate.d.ts +0 -15
  55. package/dist/language/flows/annotate-notes/annotate.js +66 -18
  56. package/dist/language/flows/flow-anchor.d.ts +12 -0
  57. package/dist/language/flows/flow-anchor.js +20 -0
  58. package/dist/language/flows/flow-expect-grammar.d.ts +2 -2
  59. package/dist/language/flows/flow-expect-grammar.js +92 -11
  60. package/dist/language/flows/flow-intent.js +1 -1
  61. package/dist/language/flows/flow-learning.d.ts +3 -0
  62. package/dist/language/flows/flow-learning.js +39 -8
  63. package/dist/language/flows/flow-replay-run.d.ts +31 -9
  64. package/dist/language/flows/flow-replay-run.js +146 -33
  65. package/dist/language/flows/flow-replay-types.d.ts +9 -0
  66. package/dist/language/flows/flow-replay.d.ts +12 -11
  67. package/dist/language/flows/flow-replay.js +79 -109
  68. package/dist/language/flows/flow-step-runners.d.ts +26 -10
  69. package/dist/language/flows/flow-step-runners.js +20 -4
  70. package/dist/language/flows/flow-success.d.ts +21 -6
  71. package/dist/language/flows/flow-success.js +50 -138
  72. package/dist/language/flows/flows.d.ts +8 -4
  73. package/dist/language/flows/flows.js +11 -2
  74. package/dist/language/flows/heal.d.ts +9 -1
  75. package/dist/language/flows/heal.js +24 -2
  76. package/dist/language/flows/learned-sources.d.ts +21 -0
  77. package/dist/language/flows/learned-sources.js +84 -0
  78. package/dist/language/flows/recording/recording-backtrack.d.ts +0 -7
  79. package/dist/language/flows/recording/recording-backtrack.js +1 -1
  80. package/dist/language/flows/recording/replay-halt.d.ts +2 -1
  81. package/dist/language/flows/recording/tape/recordings.d.ts +2 -2
  82. package/dist/language/flows/replay.js +12 -8
  83. package/dist/language/flows/stores/annotation-store.d.ts +5 -5
  84. package/dist/language/flows/stores/assertion-tiers-store.js +2 -3
  85. package/dist/language/flows/stores/flake-store.js +2 -3
  86. package/dist/language/flows/suite/coverage.d.ts +1 -1
  87. package/dist/language/flows/suite/parallel-suite.d.ts +2 -1
  88. package/dist/language/flows/verify-change-tools.js +14 -0
  89. package/dist/memory/cloud/cloud-sync.d.ts +0 -2
  90. package/dist/memory/cloud/cloud-sync.js +1 -1
  91. package/dist/memory/cloud/harness-config.d.ts +2 -0
  92. package/dist/memory/cloud/sync-cycle.d.ts +12 -0
  93. package/dist/memory/cloud/sync-cycle.js +29 -4
  94. package/dist/memory/cloud/sync-daemon.d.ts +13 -0
  95. package/dist/memory/cloud/sync-daemon.js +21 -0
  96. package/dist/memory/cloud/sync-disk.js +11 -6
  97. package/dist/memory/impact/impact-store.js +6 -0
  98. package/dist/memory/intent/inline-intent.js +8 -3
  99. package/dist/memory/intent/intent-shard-store.d.ts +33 -47
  100. package/dist/memory/intent/intent-shard-store.js +252 -140
  101. package/dist/memory/intent/intent-shard.d.ts +10 -11
  102. package/dist/memory/intent/intent-shard.js +1 -1
  103. package/dist/memory/intent/intent-store.d.ts +13 -8
  104. package/dist/memory/intent/intent-store.js +46 -90
  105. package/dist/memory/intent/intent-tools.js +34 -23
  106. package/dist/memory/journal/ambient-store.js +2 -3
  107. package/dist/memory/journal/deviation-report.js +4 -1
  108. package/dist/memory/journal/deviation-service.js +10 -2
  109. package/dist/memory/journal/drive-flow.d.ts +2 -2
  110. package/dist/memory/journal/drive-flow.js +21 -12
  111. package/dist/memory/journal/envelope-store.js +2 -3
  112. package/dist/memory/journal/journal-recorder.d.ts +21 -0
  113. package/dist/memory/journal/on-disk/retain-policy.d.ts +34 -0
  114. package/dist/memory/journal/on-disk/retain-policy.js +44 -0
  115. package/dist/memory/journal/on-disk/retention.d.ts +14 -10
  116. package/dist/memory/journal/on-disk/retention.js +13 -12
  117. package/dist/memory/journal/on-disk/startup-maintenance.d.ts +42 -0
  118. package/dist/memory/journal/on-disk/startup-maintenance.js +14 -0
  119. package/dist/memory/journal/on-disk/workspace-gitignore.d.ts +0 -11
  120. package/dist/memory/journal/on-disk/workspace-gitignore.js +6 -45
  121. package/dist/memory/journal/on-disk/workspace-tiers.d.ts +52 -0
  122. package/dist/memory/journal/on-disk/workspace-tiers.js +88 -0
  123. package/dist/memory/journal/route-template.d.ts +22 -0
  124. package/dist/memory/journal/route-template.js +44 -0
  125. package/dist/memory/journal/session-end.d.ts +4 -1
  126. package/dist/memory/journal/session-end.js +64 -6
  127. package/dist/memory/journal/session-journal.d.ts +8 -1
  128. package/dist/memory/journal/session-journal.js +10 -0
  129. package/dist/memory/project/baselines.d.ts +1 -1
  130. package/dist/memory/project/dir/reticle-dir.d.ts +1 -1
  131. package/dist/memory/project/fs/fs-port.js +44 -5
  132. package/dist/memory/project/fs/write-atomic.d.ts +26 -0
  133. package/dist/memory/project/fs/write-atomic.js +48 -0
  134. package/dist/memory/recall/prior/connection-memory.d.ts +6 -0
  135. package/dist/memory/recall/prior/connection-memory.js +8 -0
  136. package/dist/memory/recall/registered-projects.d.ts +2 -1
  137. package/dist/portal/bridge/auth-failure-reason.js +5 -2
  138. package/dist/portal/input/real-input.js +2 -1
  139. package/dist/portal/pool/browser-pool.d.ts +10 -0
  140. package/dist/portal/pool/browser-pool.js +10 -0
  141. package/dist/portal/pool/playwright-launcher.js +1 -0
  142. package/dist/portal/pool/pool-contract.d.ts +2 -0
  143. package/dist/portal/pool/storage-seed.d.ts +2 -1
  144. package/dist/portal/pool/zero-install.d.ts +27 -0
  145. package/dist/portal/pool/zero-install.js +54 -0
  146. package/dist/portal/realm/web-realm.js +6 -0
  147. package/dist/portal/session/dev-server/dev-server-probe.d.ts +2 -1
  148. package/dist/portal/session/dev-server/served-document.d.ts +21 -0
  149. package/dist/portal/session/dev-server/served-document.js +57 -0
  150. package/dist/portal/session/facts/background-traffic.d.ts +9 -0
  151. package/dist/portal/session/facts/background-traffic.js +25 -0
  152. package/dist/portal/session/live-control-tools.js +48 -3
  153. package/dist/portal/session/presence/attachment-history.d.ts +13 -2
  154. package/dist/portal/session/session-health.d.ts +61 -0
  155. package/dist/portal/session/session-health.js +40 -1
  156. package/dist/portal/session/session-info.d.ts +6 -0
  157. package/dist/portal/session/session-manager.d.ts +9 -0
  158. package/dist/portal/session/session-manager.js +23 -2
  159. package/dist/portal/session/session-successor.d.ts +11 -0
  160. package/dist/portal/session/session-successor.js +14 -1
  161. package/dist/portal/session/session-verdict-facts.d.ts +28 -0
  162. package/dist/portal/session/session-verdict-facts.js +15 -0
  163. package/dist/portal/session/session.d.ts +5 -0
  164. package/dist/portal/session/session.js +19 -7
  165. package/dist/surface/mcp/mcp-proxy.js +3 -3
  166. package/dist/surface/mcp/proxy-idle-exit.d.ts +2 -1
  167. package/dist/surface/mcp/server-instructions.js +12 -1
  168. package/dist/surface/tools/act/already-true.d.ts +40 -0
  169. package/dist/surface/tools/act/already-true.js +36 -0
  170. package/dist/surface/tools/act/navigation/navigate-arrival.d.ts +6 -0
  171. package/dist/surface/tools/act/navigation/navigate-arrival.js +13 -0
  172. package/dist/surface/tools/act/settle-in-flight.d.ts +6 -2
  173. package/dist/surface/tools/act/settle-in-flight.js +29 -6
  174. package/dist/surface/tools/act-capsule.js +22 -7
  175. package/dist/surface/tools/act-tools.js +25 -24
  176. package/dist/surface/tools/args/numeric-bounds.d.ts +15 -2
  177. package/dist/surface/tools/args/numeric-bounds.js +30 -3
  178. package/dist/surface/tools/assert/assert-grade.js +14 -2
  179. package/dist/surface/tools/assert/assert-verdict.js +30 -4
  180. package/dist/surface/tools/browser-tools.js +4 -3
  181. package/dist/surface/tools/dynamic-tools.js +6 -0
  182. package/dist/surface/tools/feedback-tools.d.ts +2 -1
  183. package/dist/surface/tools/feedback-tools.js +1 -1
  184. package/dist/surface/tools/invoke-tool.js +17 -4
  185. package/dist/surface/tools/lease-hint.d.ts +8 -0
  186. package/dist/surface/tools/lease-hint.js +10 -0
  187. package/dist/surface/tools/lease-tools.d.ts +16 -28
  188. package/dist/surface/tools/lease-tools.js +53 -5
  189. package/dist/surface/tools/lineage-tools.js +1 -1
  190. package/dist/surface/tools/observe-tools.js +14 -6
  191. package/dist/surface/tools/real-input-attempt.d.ts +0 -5
  192. package/dist/surface/tools/real-input-attempt.js +1 -1
  193. package/dist/surface/tools/tool-kit.d.ts +5 -0
  194. package/dist/surface/tools/tool-kit.js +6 -1
  195. package/dist/surface/tools/tool-surface.js +17 -5
  196. package/dist/surface/tools/tools.js +64 -29
  197. package/dist/telemetry/feedback-local.d.ts +1 -0
  198. package/dist/telemetry/feedback-local.js +1 -3
  199. package/dist/telemetry/feedback.d.ts +1 -1
  200. package/dist/telemetry/hud-metrics.d.ts +32 -0
  201. package/dist/telemetry/hud-metrics.js +95 -0
  202. package/dist/telemetry/session-metrics.d.ts +5 -0
  203. package/dist/telemetry/session-metrics.js +15 -1
  204. package/dist/wire-journal.d.ts +31 -0
  205. package/dist/wire-journal.js +100 -0
  206. package/docs/README.md +1 -0
  207. package/docs/cli/cloud.mdx +9 -7
  208. package/docs/cli/init.mdx +1 -0
  209. package/docs/cli/report.mdx +44 -0
  210. package/docs/desktop.mdx +1 -1
  211. package/docs/flows.md +11 -0
  212. package/docs/frameworks.mdx +113 -1
  213. package/docs/install-agentic.mdx +36 -0
  214. package/docs/install-manual.mdx +1 -1
  215. package/docs/packages/tauri.mdx +1 -1
  216. package/docs/packages.mdx +1 -1
  217. package/docs/predicates.mdx +128 -4
  218. package/docs/tools/query.mdx +10 -10
  219. package/docs/usage.md +1 -1
  220. package/docs/what-is-recorded.md +62 -0
  221. package/package.json +6 -6
  222. package/dist/judgement/outcome/predicate-to-expect.d.ts +0 -44
  223. package/dist/judgement/outcome/predicate-to-expect.js +0 -148
@@ -12,7 +12,7 @@
12
12
  * tool and the `@reticlehq/test` spec runner share one implementation — no divergent oracle.
13
13
  * Pure: no IO, no clock.
14
14
  */
15
- import { AnchorKind, PredicateKind } from '@reticlehq/core';
15
+ import { AnchorKind, PredicateKind, clauseOfKind, flowExpectToPredicate, withoutClauses, } from '@reticlehq/core';
16
16
  /** The dynamic (LLM-output) testids whose presence is never asserted — same rule replay uses. */
17
17
  export function dynamicTestids(flow) {
18
18
  return new Set((flow.dynamic ?? [])
@@ -23,145 +23,59 @@ export function dynamicTestids(flow) {
23
23
  export const SUCCESS_STEP_TOOL = 'success';
24
24
  /** A short human label for the success end-condition, for result rows. */
25
25
  export function successLabel(success) {
26
- if (success.signal !== undefined)
27
- return success.signal;
28
- if (success.net !== undefined)
29
- return success.net.urlContains ?? success.net.method ?? 'net';
30
- if (success.console !== undefined) {
31
- return `console:${true === success.console.absent ? 'clean' : (success.console.level ?? 'error')}`;
26
+ /*
27
+ * NOT `expectLabel`, and the difference is the reader.
28
+ *
29
+ * That one names a clause for a drift row, where `signal:` tells you which channel failed. This
30
+ * one is `mustHold` in the domain model an agent reads BEFORE testing — a list of what each flow
31
+ * guarantees — and there the prefix is noise on the commonest case. A signal's own name is the
32
+ * sentence: "order:placed" is what must hold.
33
+ */
34
+ const signal = clauseOfKind(success, PredicateKind.SIGNAL);
35
+ if (signal?.name !== undefined)
36
+ return signal.name;
37
+ const net = clauseOfKind(success, PredicateKind.NET);
38
+ if (net !== undefined)
39
+ return net.urlContains ?? net.method ?? 'net';
40
+ const console_ = clauseOfKind(success, PredicateKind.CONSOLE);
41
+ if (console_ !== undefined) {
42
+ return `console:${true === console_.absent ? 'clean' : (console_.level ?? 'error')}`;
32
43
  }
33
- if (success.state !== undefined)
34
- return `state:${success.state.path}`;
35
- if (success.text !== undefined)
36
- return `text:${success.text.contains}`;
37
- return success.element?.testid ?? success.element?.name ?? success.element?.role ?? 'success';
44
+ const state = clauseOfKind(success, PredicateKind.STATE);
45
+ if (state !== undefined)
46
+ return `state:${state.path}`;
47
+ const text = clauseOfKind(success, PredicateKind.TEXT);
48
+ if (text?.contains !== undefined)
49
+ return `text:${text.contains}`;
50
+ const element = clauseOfKind(success, PredicateKind.ELEMENT);
51
+ const q = element?.query;
52
+ return q?.testid ?? q?.name ?? q?.role ?? 'success';
38
53
  }
39
- /** Compile a success FlowExpect into a predicate. undefined → nothing assertable (vacuously met). */
54
+ /**
55
+ * What a flow's success oracle actually asserts, once the file has been read and the dynamic
56
+ * (LLM-output) testids have been taken out of it.
57
+ *
58
+ * Most of what this function used to do is gone, and that is the migration working as intended: it
59
+ * compiled a flat `FlowExpect` into a `Predicate`, and a step's `expect` IS a predicate now, lifted
60
+ * by the schema when the file is read. It keeps the name and the signature because it is on
61
+ * `@reticlehq/test`'s published surface, and it still accepts the flat shape for the same reason a
62
+ * v1 file still loads — a spec written against the old spelling is not wrong, it is old.
63
+ *
64
+ * What remains is the DYNAMIC SKIP, and it is not cosmetic. A testid whose content a model writes is
65
+ * asserted for PRESENCE by the step layer and never for content; a success oracle bound to one would
66
+ * assert exactly what the flow has already declared unassertable. Dropping every such clause can
67
+ * leave nothing at all, and `undefined` is the honest answer — vacuously met, the same as declaring
68
+ * no success, because nothing here was checkable.
69
+ */
40
70
  export function successToPredicate(success, dynamic) {
41
- const parts = [];
42
- if (success.signal !== undefined) {
43
- const signal = {
44
- kind: PredicateKind.SIGNAL,
45
- name: success.signal,
46
- };
47
- if (success.signalData !== undefined)
48
- signal.dataMatches = success.signalData;
49
- if (success.signalCount !== undefined) {
50
- signal.count = success.signalCount;
51
- // Same post-settle reasoning as net.count: the success waiter is wait-until-true, so an exact
52
- // count is transiently satisfied the instant the FIRST matching signal fires — before a
53
- // double-fire's duplicate arrives. Gating on `settled` forces the count to be read only after
54
- // the app goes quiet, by which point the duplicate IS counted and the over-count fails.
55
- parts.push({ kind: PredicateKind.SETTLED });
56
- }
57
- parts.push(signal);
58
- }
59
- if (success.route !== undefined) {
60
- // The mirror of `predicateToExpect`'s ROUTE case. Replay evaluates predicates, so carrying the
61
- // route back here is the whole of what makes a recorded navigation able to fail.
62
- const route = {
63
- kind: PredicateKind.ROUTE,
64
- };
65
- if (success.route.pathname !== undefined)
66
- route.pathname = success.route.pathname;
67
- if (success.route.contains !== undefined)
68
- route.contains = success.route.contains;
69
- return route;
70
- }
71
- if (success.net !== undefined) {
72
- const net = { kind: PredicateKind.NET };
73
- if (success.net.method !== undefined)
74
- net.method = success.net.method;
75
- if (success.net.urlContains !== undefined)
76
- net.urlContains = success.net.urlContains;
77
- if (success.net.status !== undefined)
78
- net.status = success.net.status;
79
- if (success.net.count !== undefined) {
80
- net.count = success.net.count;
81
- // A cardinality assertion is inherently POST-SETTLE. The success waiter is wait-until-true, so an
82
- // exact count (e.g. 1) is transiently satisfied the instant the FIRST matching request lands —
83
- // before a double-submit's duplicate arrives. Gating on `settled` forces the count to be read
84
- // only after the network has gone quiet, so the duplicate IS counted and the over-count fails.
85
- parts.push({ kind: PredicateKind.SETTLED });
86
- }
87
- parts.push(net);
88
- }
89
- if (success.console !== undefined) {
90
- const con = {
91
- kind: PredicateKind.CONSOLE,
92
- };
93
- if (success.console.level !== undefined)
94
- con.level = success.console.level;
95
- if (success.console.absent !== undefined) {
96
- con.absent = success.console.absent;
97
- // An `absent` assertion is post-settle, same as net.count: a wait-until-true waiter sees "no
98
- // error yet" at the first poll and passes BEFORE the action's error fires. Gate on `settled` so
99
- // the console is read only after the page quiets, by which point any error is in the buffer.
100
- if (success.console.absent)
101
- parts.push({ kind: PredicateKind.SETTLED });
102
- }
103
- parts.push(con);
104
- }
105
- const state = success.state;
106
- if (state !== undefined) {
107
- const part = {
108
- kind: PredicateKind.STATE,
109
- path: state.path,
110
- };
111
- if (state.store !== undefined)
112
- part.store = state.store;
113
- if (state.equals !== undefined)
114
- part.equals = state.equals;
115
- // `hold` = an INVARIANT ("this state must still hold after the action settles"), vs the default
116
- // wait-for-change. A wait-until-true waiter would pass the instant the path already equals the
117
- // value — which, for "an unrelated path stayed put", is true BEFORE a side-effect leak fires. Gate
118
- // on `settled` so the read happens after the page quiets, by which point the leak has landed.
119
- if (true === state.hold)
120
- parts.push({ kind: PredicateKind.SETTLED });
121
- parts.push(part);
122
- }
123
- const element = success.element;
124
- if (element !== undefined) {
125
- const testid = element.testid;
126
- // A dynamic-marked testid is NOT asserted as a success condition (presence-only).
127
- if (testid === undefined || !dynamic.has(testid)) {
128
- const query = {};
129
- if (testid !== undefined)
130
- query['testid'] = testid;
131
- if (element.role !== undefined)
132
- query['role'] = element.role;
133
- if (element.name !== undefined)
134
- query['name'] = element.name;
135
- if (Object.keys(query).length > 0)
136
- parts.push({ kind: PredicateKind.ELEMENT, query });
137
- }
138
- }
139
- const text = success.text;
140
- if (text !== undefined) {
141
- const part = {
142
- kind: PredicateKind.TEXT,
143
- contains: text.contains,
144
- };
145
- if (text.scope !== undefined)
146
- part.scope = text.scope;
147
- if (text.visible !== undefined)
148
- part.visible = text.visible;
149
- if (true === text.absent) {
150
- part.absent = true;
151
- // Same post-settle reasoning as `console.absent` and `state.hold`: a wait-until-true waiter
152
- // reads "not there yet" on the first poll and passes BEFORE the text it is meant to see
153
- // disappear has even been rendered. Gate on `settled` so the read happens after the page
154
- // quiets, by which point a text that was going to appear has.
155
- parts.push({ kind: PredicateKind.SETTLED });
156
- }
157
- parts.push(part);
158
- }
159
- const [first] = parts;
160
- if (0 === parts.length)
71
+ if (success === undefined)
161
72
  return undefined;
162
- if (1 === parts.length && first !== undefined)
163
- return first;
164
- return { kind: PredicateKind.ALL_OF, predicates: parts };
73
+ const predicate = 'kind' in success ? success : flowExpectToPredicate(success);
74
+ if (predicate === undefined)
75
+ return undefined;
76
+ return withoutClauses(predicate, (clause) => PredicateKind.ELEMENT === clause.kind &&
77
+ clause.query.testid !== undefined &&
78
+ dynamic.has(clause.query.testid));
165
79
  }
166
80
  /**
167
81
  * Assert a flow's success end-condition after replay. Delegates to the injected waitForSignal (the
@@ -170,8 +84,6 @@ export function successToPredicate(success, dynamic) {
170
84
  * the injected timeout. Never reads the wall clock.
171
85
  */
172
86
  export async function assertSuccess(session, success, dynamic, waitForSignal, timeoutMs, since = 0) {
173
- if (success === undefined)
174
- return { pass: true };
175
87
  const predicate = successToPredicate(success, dynamic);
176
88
  if (predicate === undefined)
177
89
  return { pass: true };
@@ -1,8 +1,8 @@
1
- import { type ProjectId } from '@reticlehq/core';
1
+ import { type Predicate, type ProjectId } from '@reticlehq/core';
2
2
  export { REDACTED_FILL } from './fields/flow-secret-field.js';
3
3
  import { type FlowResult } from './flow-result.js';
4
4
  export type { FlowResult } from './flow-result.js';
5
- import type { FlowAnchor, FlowExpect, FlowFile, FlowStep, HealChange, InstrumentationGap } from '@reticlehq/core';
5
+ import type { FlowAnchor, FlowFile, FlowStep, HealChange, InstrumentationGap } from '@reticlehq/core';
6
6
  import type { CompiledProgram, RecordedStep } from './recording/tape/recordings.js';
7
7
  import type { FileSystemPort } from '../../memory/project/fs/fs-port.js';
8
8
  import type { Clock } from '../../machine/clock.js';
@@ -49,9 +49,9 @@ interface SaveSummary {
49
49
  * the same bytes as before.
50
50
  */
51
51
  export interface FlowAnnotations {
52
- stepExpect: Map<number, FlowExpect>;
52
+ stepExpect: Map<number, Predicate>;
53
53
  dynamic: string[];
54
- success?: FlowExpect;
54
+ success?: Predicate;
55
55
  /** The flow's declared business goal (intent annotation). */
56
56
  intent?: string;
57
57
  }
@@ -75,6 +75,10 @@ export declare class FlowStore {
75
75
  recordLearned(name: string, learned: NonNullable<FlowFile['learned']>, projectId?: ProjectId): Promise<FlowResult<{
76
76
  name: string;
77
77
  }>>;
78
+ /** Give sourceless steps the files a clean replay resolved them to, merged onto the file as it is now. */
79
+ recordSources(name: string, sources: ReadonlyMap<string, NonNullable<FlowStep['source']>>, projectId?: ProjectId): Promise<FlowResult<{
80
+ name: string;
81
+ }>>;
78
82
  /**
79
83
  * Apply confident testid rebinds to an on-disk flow (the `reticle_flow_heal` apply path),
80
84
  * rewriting ONLY the named steps' anchors and preserving every other field. Loading, the name
@@ -1,4 +1,4 @@
1
- import { asFlowName, asProjectId } from '@reticlehq/core';
1
+ import { asFlowName, asProjectId, } from '@reticlehq/core';
2
2
  import { REDACTED_FILL } from './fields/flow-secret-field.js';
3
3
  export { REDACTED_FILL } from './fields/flow-secret-field.js';
4
4
  import { safeProjectId } from './flow-result.js';
@@ -7,6 +7,7 @@ import { AnchorKind, DEGRADED_ANCHOR_ROLE, FLOW_FILE_VERSION, FlowErrorCode, Flo
7
7
  import { ReticleTool } from '@reticlehq/core';
8
8
  import { asRecord, asString } from '@reticlehq/core';
9
9
  import { applyHealChanges } from './heal.js';
10
+ import { withLearnedSources } from './learned-sources.js';
10
11
  import { flowIntentGap, linkFlowIntent } from './flow-intent.js';
11
12
  import { IntentStore } from '../../memory/intent/intent-store.js';
12
13
  import { flowDir, flowParentDir, flowPath, reticleDirPaths, isValidFlowName, } from '../../memory/project/dir/reticle-dir.js';
@@ -351,6 +352,13 @@ export class FlowStore {
351
352
  value: { name },
352
353
  }));
353
354
  }
355
+ /** Give sourceless steps the files a clean replay resolved them to, merged onto the file as it is now. */
356
+ async recordSources(name, sources, projectId) {
357
+ return await this.#changeInPlace(name, projectId, (flow) => ({
358
+ next: withLearnedSources(flow, sources) ?? flow,
359
+ value: { name },
360
+ }));
361
+ }
354
362
  /**
355
363
  * Apply confident testid rebinds to an on-disk flow (the `reticle_flow_heal` apply path),
356
364
  * rewriting ONLY the named steps' anchors and preserving every other field. Loading, the name
@@ -362,7 +370,8 @@ export class FlowStore {
362
370
  */
363
371
  async heal(name, changes, projectId) {
364
372
  return await this.#changeInPlace(name, projectId, (flow) => {
365
- const { flow: next, applied } = applyHealChanges(flow, changes);
373
+ // The store's injected clock, so a written flow records when the rebind actually happened.
374
+ const { flow: next, applied } = applyHealChanges(flow, changes, () => this.#clock.now());
366
375
  return { next, value: { name, changed: applied } };
367
376
  });
368
377
  }
@@ -25,7 +25,15 @@ export declare function confidenceFor(from: string, to: string): number;
25
25
  * skipped (idempotent / defensive), never throwing. Shared by the on-disk writer (FlowStore.heal)
26
26
  * and the in-memory pre-write verification in reticle_flow_heal, so both rewrite identically.
27
27
  */
28
- export declare function applyHealChanges(flow: FlowFile, changes: HealChange[]): {
28
+ export declare function applyHealChanges(flow: FlowFile, changes: HealChange[],
29
+ /**
30
+ * When the rebind happened. Injected rather than read here, because this function is pure and a
31
+ * clock inside it would make the flow it returns depend on when a test ran.
32
+ *
33
+ * Optional so every existing caller keeps working; without it the step still records WHICH anchor
34
+ * was replaced, which is the half a reader actually acts on.
35
+ */
36
+ now?: () => number): {
29
37
  flow: FlowFile;
30
38
  applied: HealChange[];
31
39
  };
@@ -38,7 +38,15 @@ export function confidenceFor(from, to) {
38
38
  * skipped (idempotent / defensive), never throwing. Shared by the on-disk writer (FlowStore.heal)
39
39
  * and the in-memory pre-write verification in reticle_flow_heal, so both rewrite identically.
40
40
  */
41
- export function applyHealChanges(flow, changes) {
41
+ export function applyHealChanges(flow, changes,
42
+ /**
43
+ * When the rebind happened. Injected rather than read here, because this function is pure and a
44
+ * clock inside it would make the flow it returns depend on when a test ran.
45
+ *
46
+ * Optional so every existing caller keeps working; without it the step still records WHICH anchor
47
+ * was replaced, which is the half a reader actually acts on.
48
+ */
49
+ now) {
42
50
  const byStep = new Map();
43
51
  for (const change of changes)
44
52
  byStep.set(change.step, change);
@@ -51,7 +59,21 @@ export function applyHealChanges(flow, changes) {
51
59
  return step;
52
60
  }
53
61
  applied.push(change);
54
- return { ...step, anchor: { kind: AnchorKind.TESTID, value: change.to } };
62
+ return {
63
+ ...step,
64
+ anchor: { kind: AnchorKind.TESTID, value: change.to },
65
+ /*
66
+ * `from` is the ORIGINAL recorded anchor, kept across later heals.
67
+ *
68
+ * A step healed twice has an intermediate name nobody ever chose, so overwriting with the
69
+ * newest would throw away the only anchor a reader wants — the one that was recorded by
70
+ * driving. The timestamp is the LATEST heal, because that is when this anchor arrived.
71
+ */
72
+ healed: {
73
+ from: step.healed?.from ?? change.from,
74
+ at: now?.() ?? step.healed?.at ?? 0,
75
+ },
76
+ };
55
77
  });
56
78
  return { flow: { ...flow, steps }, applied };
57
79
  }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Learn a flow step's source file from the element its anchor resolved to on a replay.
3
+ *
4
+ * A step recorded before the build stamped the app names no file, so a scoped re-verify cannot say
5
+ * which edits it covers. Every replay already asks the page for each anchor's element, and that
6
+ * element carries its stamp now: this records the answer at the one place every resolution passes
7
+ * through (the session's QUERY), rather than threading it through each step runner.
8
+ */
9
+ import { type FlowFile, type FlowStep } from '@reticlehq/core';
10
+ import type { FlowReplaySession } from './flow-replay-types.js';
11
+ type StepSource = NonNullable<FlowStep['source']>;
12
+ /** `src/Pay.tsx:12:4` or `src/Pay.tsx:12` → `{file, line}`; anything else is not a location. */
13
+ export declare function parseCompactSource(value: string): StepSource | undefined;
14
+ /** Wrap a replay session so every anchor QUERY that resolves to one stamped element is remembered. */
15
+ export declare function recordingSources(inner: FlowReplaySession): {
16
+ session: FlowReplaySession;
17
+ sources: ReadonlyMap<string, StepSource>;
18
+ };
19
+ /** The flow with every sourceless step given the file its anchor resolved to; undefined if none was. */
20
+ export declare function withLearnedSources(flow: FlowFile, sources: ReadonlyMap<string, StepSource>): FlowFile | undefined;
21
+ export {};
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Learn a flow step's source file from the element its anchor resolved to on a replay.
3
+ *
4
+ * A step recorded before the build stamped the app names no file, so a scoped re-verify cannot say
5
+ * which edits it covers. Every replay already asks the page for each anchor's element, and that
6
+ * element carries its stamp now: this records the answer at the one place every resolution passes
7
+ * through (the session's QUERY), rather than threading it through each step runner.
8
+ */
9
+ import { ReticleCommand, asRecord, asString } from '@reticlehq/core';
10
+ import { anchorQueryArgs } from './flow-step-runners.js';
11
+ /** `src/Pay.tsx:12:4` or `src/Pay.tsx:12` → `{file, line}`; anything else is not a location. */
12
+ export function parseCompactSource(value) {
13
+ const match = /^(.+?):(\d+)(?::\d+)?$/.exec(value);
14
+ const file = match?.[1];
15
+ const line = match?.[2];
16
+ if (file === undefined || line === undefined)
17
+ return undefined;
18
+ return { file, line: Number(line) };
19
+ }
20
+ function keyOf(args) {
21
+ if (args === undefined)
22
+ return undefined;
23
+ const by = asString(args['by']);
24
+ const value = asString(args['value']);
25
+ if (by === undefined || value === undefined)
26
+ return undefined;
27
+ return JSON.stringify([by, value, asString(args['name']) ?? null]);
28
+ }
29
+ /** The one element a query found, when exactly one was found and it carries a stamp. */
30
+ function soleSource(result) {
31
+ const elements = asRecord(result)?.['elements'];
32
+ if (!Array.isArray(elements) || elements.length !== 1)
33
+ return undefined;
34
+ const source = asString(asRecord(elements[0])?.['source']);
35
+ return source === undefined ? undefined : parseCompactSource(source);
36
+ }
37
+ /** Wrap a replay session so every anchor QUERY that resolves to one stamped element is remembered. */
38
+ export function recordingSources(inner) {
39
+ const sources = new Map();
40
+ const session = new Proxy(inner, {
41
+ // Bound to the target, never the proxy: a real Session keeps `#private` state, and a method
42
+ // called with the proxy as `this` throws reading it.
43
+ get(target, prop) {
44
+ if (prop !== 'command') {
45
+ const value = Reflect.get(target, prop, target);
46
+ return 'function' === typeof value ? value.bind(target) : value;
47
+ }
48
+ return async (name, args) => {
49
+ const result = await target.command(name, args);
50
+ const key = name === ReticleCommand.QUERY ? keyOf(args) : undefined;
51
+ const source = result.ok ? soleSource(result.result) : undefined;
52
+ if (key !== undefined && source !== undefined)
53
+ sources.set(key, source);
54
+ return result;
55
+ };
56
+ },
57
+ });
58
+ return { session, sources };
59
+ }
60
+ function learnSteps(steps, sources) {
61
+ let changed = false;
62
+ const next = steps.map((step) => {
63
+ const nested = step.steps === undefined ? undefined : learnSteps(step.steps, sources);
64
+ const learned = step.source === undefined
65
+ ? sources.get(keyOf(anchorQueryArgs(step.anchor) ?? undefined) ?? '')
66
+ : undefined;
67
+ if (learned === undefined && nested?.changed !== true)
68
+ return step;
69
+ changed = true;
70
+ return {
71
+ ...step,
72
+ ...(learned === undefined ? {} : { source: learned }),
73
+ ...(nested === undefined ? {} : { steps: nested.steps }),
74
+ };
75
+ });
76
+ return { steps: next, changed };
77
+ }
78
+ /** The flow with every sourceless step given the file its anchor resolved to; undefined if none was. */
79
+ export function withLearnedSources(flow, sources) {
80
+ if (0 === sources.size)
81
+ return undefined;
82
+ const { steps, changed } = learnSteps(flow.steps, sources);
83
+ return changed ? { ...flow, steps } : undefined;
84
+ }
@@ -7,13 +7,6 @@
7
7
  * stop/save can warn instead of writing a flow that fails on the next click.
8
8
  */
9
9
  import { type ReticleEvent } from '@reticlehq/core';
10
- /**
11
- * The page a recording step sat on, comparable across path routers and hash routers.
12
- *
13
- * A startPath is stored as pathname+hash (`/#/search`); a ROUTE_CHANGE carries them separately.
14
- * Without this, every hash-router page compares as `/` and a backtrack is invisible.
15
- */
16
- export declare function recordingRouteOf(pathname: string, hash?: string): string;
17
10
  /**
18
11
  * Warn when the journey left a page and came back. Linear A→B→C is silent: that still cannot
19
12
  * replay either if a later click needs a previous page, but the reported failure is specifically
@@ -17,7 +17,7 @@ function asString(value) {
17
17
  * A startPath is stored as pathname+hash (`/#/search`); a ROUTE_CHANGE carries them separately.
18
18
  * Without this, every hash-router page compares as `/` and a backtrack is invisible.
19
19
  */
20
- export function recordingRouteOf(pathname, hash = '') {
20
+ function recordingRouteOf(pathname, hash = '') {
21
21
  const hashIdx = pathname.indexOf('#/');
22
22
  if (hashIdx >= 0) {
23
23
  return routePathOf(pathname.slice(0, hashIdx) || '/', pathname.slice(hashIdx));
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import type { FlowStepResult } from '@reticlehq/core';
14
14
  /** Set on a replay that stopped early; omitted entirely otherwise, so a clean pass stays flat. */
15
- export interface ReplayHalt {
15
+ interface ReplayHalt {
16
16
  /** Index of the step that stopped the run. */
17
17
  atStep: number;
18
18
  /** How many of the flow's steps were never attempted because of it. */
@@ -24,3 +24,4 @@ export interface ReplayHalt {
24
24
  * defect restated as its own fix.
25
25
  */
26
26
  export declare function haltedFrom(results: readonly FlowStepResult[], total: number): ReplayHalt | undefined;
27
+ export {};
@@ -1,4 +1,4 @@
1
- import { type FlowExpect } from '@reticlehq/core';
1
+ import { type Predicate } from '@reticlehq/core';
2
2
  /** One captured agent action, normalized for replay. */
3
3
  export interface RecordedStep {
4
4
  /** ReticleTool.ACT | ReticleTool.ACT_SEQUENCE */
@@ -8,7 +8,7 @@ export interface RecordedStep {
8
8
  /** false if any ref could not be normalized to a testid (replay only valid in-session). */
9
9
  stable: boolean;
10
10
  /** Optional post-condition annotation carried into the on-disk flow's expect. */
11
- expect?: FlowExpect;
11
+ expect?: Predicate;
12
12
  /**
13
13
  * The route this step ran on. RECORDER-INTERNAL: it never reaches the on-disk flow.
14
14
  *
@@ -1,9 +1,8 @@
1
1
  import { REDACTED_FILL, secretEnvKey } from './fields/flow-secret-field.js';
2
+ import { FlowPredicateSchema } from '@reticlehq/core';
2
3
  import { DANGEROUS_ACTION_CONFIRM_ARG, ReticleCommand, QueryBy, } from '@reticlehq/core';
3
4
  import { ReticleTool } from '@reticlehq/core';
4
5
  import { formatStepAddress } from 'open-verification';
5
- import { predicateToExpect, enforcedOnReplay } from '../../judgement/outcome/predicate-to-expect.js';
6
- import { PredicateSchema } from '@reticlehq/engine/question/predicate/predicate.js';
7
6
  import { asRecord, asString } from '@reticlehq/core';
8
7
  /**
9
8
  * The note attached when a testid resolves to multiple live elements (the first match is used).
@@ -105,16 +104,21 @@ route) {
105
104
  /**
106
105
  * A declared predicate, as a recorded expectation — or nothing.
107
106
  *
108
- * The ONE place this conversion happens, because it is the rule that decides whether a saved flow
109
- * proves anything, and two copies of it would drift. Only kinds a replay actually CHECKS survive:
110
- * recording an unenforced assertion grades the flow "asserted" while nothing verifies it, which is a
111
- * false green inside the feature built to prevent them. See enforcedOnReplay.
107
+ * It is now the SAME OBJECT. A step's `expect` is a `Predicate`, so what the agent declared in
108
+ * `until` is what the flow file stores, and there is no conversion left to lose anything in.
109
+ *
110
+ * What was here before was a lossy round trip through a flat struct with one slot per kind. An
111
+ * `allOf[netA, netB]` saved as one of the two with nothing said; anything the struct could not
112
+ * express was dropped, and a filter then removed the kinds replay would not have checked anyway.
113
+ * Every one of those was a claim the agent made and the file did not keep.
112
114
  */
113
115
  function enforceableExpect(raw) {
114
116
  if (raw === undefined)
115
117
  return undefined;
116
- const parsed = PredicateSchema.safeParse(raw);
117
- return parsed.success ? enforcedOnReplay(predicateToExpect(parsed.data)) : undefined;
118
+ // The flow file's own schema, so capture keeps exactly what a load would accept — a predicate
119
+ // holding a session ref is refused by both, rather than recorded here and rejected on read.
120
+ const parsed = FlowPredicateSchema.safeParse(raw);
121
+ return parsed.success ? parsed.data : undefined;
118
122
  }
119
123
  /**
120
124
  * Compile a single reticle_act invocation into a normalized RecordedStep using the action result. Anchor
@@ -1,22 +1,22 @@
1
- import type { FlowExpect } from '@reticlehq/core';
1
+ import type { Predicate } from '@reticlehq/core';
2
2
  export declare class AnnotationStore {
3
3
  #private;
4
4
  /** Testids whose CONTENT must not be asserted at replay (mark-dynamic). Copy, never the live array. */
5
5
  dynamic(name: string): string[];
6
6
  /** The flow's golden end-condition (success-state), or undefined. */
7
- success(name: string): FlowExpect | undefined;
7
+ success(name: string): Predicate | undefined;
8
8
  /** The flow's declared business goal (intent annotation), or undefined. */
9
9
  intent(name: string): string | undefined;
10
10
  /** Per-step expect predicates compiled from assert-* annotations, keyed by step index. */
11
- stepExpect(name: string): Map<number, FlowExpect>;
11
+ stepExpect(name: string): Map<number, Predicate>;
12
12
  /** Append a dynamic testid (mark-dynamic). De-duped so repeated marks stay idempotent. */
13
13
  addDynamic(name: string, testid: string): void;
14
14
  /** Set the flow's success expectation (success-state); a later set overwrites. */
15
- setSuccess(name: string, expect: FlowExpect): void;
15
+ setSuccess(name: string, expect: Predicate): void;
16
16
  /** Set the flow's declared business goal (intent); a later set overwrites. */
17
17
  setIntent(name: string, text: string): void;
18
18
  /** Set one step's expect (assert-signal / assert-visible); a later set on the same index wins. */
19
- setStepExpect(name: string, index: number, expect: FlowExpect): void;
19
+ setStepExpect(name: string, index: number, expect: Predicate): void;
20
20
  /** Drop a recording's annotations (called after reticle_flow_save folds them onto disk). */
21
21
  clear(name: string): void;
22
22
  }
@@ -1,4 +1,5 @@
1
1
  import { dirname } from 'node:path';
2
+ import { writeFileAtomic } from '../../../memory/project/fs/write-atomic.js';
2
3
  import { z } from 'zod';
3
4
  import { reticleDirPaths } from '../../../memory/project/dir/reticle-dir.js';
4
5
  import { withFileLock } from '../../../memory/project/file-lock.js';
@@ -77,9 +78,7 @@ export class AssertionTiersStore {
77
78
  async save(tiers) {
78
79
  await this.#fs.mkdir(dirname(this.#path));
79
80
  const body = `${JSON.stringify({ version: ASSERTION_TIERS_VERSION, flows: tiers }, null, 2)}\n`;
80
- const tmp = `${this.#path}.tmp`;
81
- await this.#fs.writeFile(tmp, body);
82
- await this.#fs.rename(tmp, this.#path);
81
+ await writeFileAtomic(this.#fs, this.#path, body);
83
82
  }
84
83
  /** Record one flow's assertion shape + covered sources after it PASSED. Never breaks a replay. */
85
84
  async recordPassing(name, steps, sources = []) {
@@ -1,4 +1,5 @@
1
1
  import { dirname } from 'node:path';
2
+ import { writeFileAtomic } from '../../../memory/project/fs/write-atomic.js';
2
3
  import { reticleDirPaths } from '../../../memory/project/dir/reticle-dir.js';
3
4
  import { FlakeFileSchema, emptyRecord, isFlaky, recordOutcome, } from '../recording/flake.js';
4
5
  const FLAKE_FILE_VERSION = 1;
@@ -38,9 +39,7 @@ export class FlakeStore {
38
39
  async save(ledger) {
39
40
  await this.#fs.mkdir(dirname(this.#path));
40
41
  const body = `${JSON.stringify({ version: FLAKE_FILE_VERSION, flows: ledger }, null, 2)}\n`;
41
- const tmp = `${this.#path}.tmp`;
42
- await this.#fs.writeFile(tmp, body);
43
- await this.#fs.rename(tmp, this.#path);
42
+ await writeFileAtomic(this.#fs, this.#path, body);
44
43
  }
45
44
  /** Accrue one unchanged-code replay outcome for a flow and persist. */
46
45
  async record(flow, passed) {
@@ -24,7 +24,7 @@ interface CoverageDimension {
24
24
  /** Declared-but-never-exercised members — what "everything" is silently missing. */
25
25
  uncovered: string[];
26
26
  }
27
- export interface Coverage {
27
+ interface Coverage {
28
28
  testids: CoverageDimension;
29
29
  signals: CoverageDimension;
30
30
  flows: CoverageDimension;
@@ -9,7 +9,7 @@
9
9
  * - one flow throwing never sinks the run — it is captured as that item's outcome, because a suite that
10
10
  * aborts on the first crash tells you nothing about the other 199 flows.
11
11
  */
12
- export interface Outcome<T> {
12
+ interface Outcome<T> {
13
13
  ok: boolean;
14
14
  value?: T;
15
15
  error?: string;
@@ -24,3 +24,4 @@ export declare function mapWithConcurrency<I, T>(items: readonly I[], limit: num
24
24
  * and never below 1. Explicit `requested` (the tool's `parallel` arg) wins when it is a sane number.
25
25
  */
26
26
  export declare function resolveConcurrency(flowCount: number, poolCapacity: number, requested?: number): number;
27
+ export {};