@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
@@ -184,6 +184,19 @@ export const VERIFY_CHANGE_TOOLS = [
184
184
  measured: extra.measured,
185
185
  };
186
186
  }
187
+ // The mirror of `earnedNo`: a green from flows that ran only because their sources are unknown
188
+ // says nothing about these files either. At least one attributed flow has to have passed.
189
+ if (0 === attributedFailures(affected, unknownProvenance).length) {
190
+ return {
191
+ verified: Verified.UNKNOWN,
192
+ because: `the flows that ran passed, but Reticle cannot tell which sources any of them cover (${affected.join(', ')}), so this says nothing about the files you changed. Drive the change directly; the drive is saved as a flow with its sources stamped`,
193
+ changedFiles,
194
+ flowsRun: affected,
195
+ suite,
196
+ unknownProvenance,
197
+ measured: extra.measured,
198
+ };
199
+ }
187
200
  return {
188
201
  verified: Verified.YES,
189
202
  because: `all ${String(passed)} flows covering these files passed${provenanceNote}${extra.untouched === undefined ? '' : `, with ${String(extra.untouched.length)} controls left undriven`}`,
@@ -226,6 +239,7 @@ async function inspectAfterReplay(deps, args, cursor) {
226
239
  currentDocumentId: session.currentDocumentId,
227
240
  currentEditEpoch: session.currentEditEpoch,
228
241
  appOrigin: session.url,
242
+ background: session.background,
229
243
  // The replay is the action, and the cursor is where it started.
230
244
  actionSince: cursor,
231
245
  });
@@ -175,8 +175,6 @@ export declare function submitServerVerification(body: {
175
175
  * allowed to influence a verdict or an exit code.
176
176
  */
177
177
  export declare function syncProgressToCloud(runId: string, events: readonly VerifyProgressEvent[], config: CloudConfig | null, fetchImpl: FetchLike): Promise<SyncResult>;
178
- /** How long events accumulate before a flush. See the batching note above. */
179
- export declare const PROGRESS_FLUSH_MS = 2000;
180
178
  /**
181
179
  * A listener that buffers progress and ships it on a timer.
182
180
  *
@@ -293,7 +293,7 @@ export async function syncProgressToCloud(runId, events, config, fetchImpl) {
293
293
  }
294
294
  }
295
295
  /** How long events accumulate before a flush. See the batching note above. */
296
- export const PROGRESS_FLUSH_MS = 2_000;
296
+ const PROGRESS_FLUSH_MS = 2_000;
297
297
  /**
298
298
  * A listener that buffers progress and ships it on a timer.
299
299
  *
@@ -3,6 +3,8 @@ export interface HarnessConfigView {
3
3
  provider: string;
4
4
  harnessEnabled: boolean;
5
5
  harnessEntitled: boolean;
6
+ /** Whether the platform holds a key for `provider`. Entitlement is not readiness — see the offer. */
7
+ providerReady: boolean;
6
8
  }
7
9
  export interface ConfigSource {
8
10
  read(): HarnessConfigView | undefined;
@@ -61,8 +61,20 @@ export interface CloudSyncState {
61
61
  lastPullAt?: number;
62
62
  /** The last failure, kept so `reticle sync` can say why it is behind instead of just "0 sent". */
63
63
  lastError?: string;
64
+ /**
65
+ * Run ids this machine has watched the server accept, kept only for the runs still held locally.
66
+ *
67
+ * Read ONLY when the server says its own `knownRunIds` was truncated. A complete list is a
68
+ * complete answer — a run missing from one is genuinely missing and must be re-sent — so this
69
+ * fills the gap the server declared and never overrides the server on a question it answered.
70
+ */
71
+ sentRunIds?: string[];
64
72
  }
65
73
  export interface SyncReport {
74
+ /**
75
+ * False if the cycle failed or any offered run was rejected. A completed HTTP exchange alone
76
+ * is not a successful push. Accepted counts and rejection reasons still describe partial success.
77
+ */
66
78
  ok: boolean;
67
79
  /** Runs the server accepted this cycle. */
68
80
  runsSent: number;
@@ -99,9 +99,20 @@ export async function runSyncCycle(deps) {
99
99
  * something the server does not have. Silent data loss is the one failure this protocol must
100
100
  * not have, and an unreadable answer has to mean "it knows nothing", never "it knows this".
101
101
  */
102
- const known = new Set(Array.isArray(held.knownRunIds)
103
- ? held.knownRunIds.filter((id) => 'string' === typeof id)
104
- : []);
102
+ const onlyStrings = (value) => Array.isArray(value) ? value.filter((id) => 'string' === typeof id) : [];
103
+ /*
104
+ * The server may answer "here are the ids I hold, and there are more than I listed". Read as the
105
+ * whole truth, that short list made every run past the server's page look unsent, so a project
106
+ * with enough runs to truncate it re-uploaded its history on every single cycle, forever. The
107
+ * field was declared on this response and read nowhere.
108
+ *
109
+ * Only then is the machine's own record consulted, and it is subject to the same rule as the
110
+ * server's: anything that is not a list of strings reads as "nothing is known".
111
+ */
112
+ const known = new Set([
113
+ ...onlyStrings(held.knownRunIds),
114
+ ...(true === held.truncated ? onlyStrings(deps.state.sentRunIds) : []),
115
+ ]);
105
116
  // Same rule for the hashes: only a string can equal a hash we computed, so anything else reads
106
117
  // as "unknown" and the record is sent once. Re-sending costs a request; skipping costs the data.
107
118
  const hashes = isRecord(held.stateHashes) ? held.stateHashes : {};
@@ -158,7 +169,20 @@ export async function runSyncCycle(deps) {
158
169
  ? runs['rejected']
159
170
  : [];
160
171
  nextState.lastPushAt = deps.now();
172
+ // Accepted means the server has it. A rejected run was refused by index, so it is exactly as
173
+ // unsent as it was before and must never be remembered as delivered.
174
+ const refused = new Set(runsRejected.map((r) => r.index));
175
+ unsent.forEach((run, index) => {
176
+ if (!refused.has(index))
177
+ known.add(run.runId);
178
+ });
161
179
  }
180
+ /*
181
+ * Bounded by the runs still on disk rather than by a cap. Retention already decides how many
182
+ * runs a workspace keeps, and an id for a run nobody holds any more cannot stop an upload that
183
+ * will never be attempted — so the record follows the artifacts and needs no number of its own.
184
+ */
185
+ nextState.sentRunIds = allRuns.map((r) => r.runId).filter((id) => known.has(id));
162
186
  // 3. COLLECT — always, even when there was nothing to send.
163
187
  const query = nextState.cursor === undefined ? '' : `?since=${encodeURIComponent(nextState.cursor)}`;
164
188
  const pull = await call(`${SYNC_PULL_PATH}${query}`);
@@ -199,7 +223,8 @@ export async function runSyncCycle(deps) {
199
223
  delete nextState.lastError;
200
224
  deps.sink.writeState(nextState);
201
225
  return {
202
- ok: true,
226
+ // Keep transport errors separate: rejection reasons already identify the failed artifacts.
227
+ ok: 0 === runsRejected.length,
203
228
  runsSent,
204
229
  runsRejected,
205
230
  flowsSent,
@@ -45,6 +45,19 @@ export interface SyncDaemon {
45
45
  */
46
46
  nudge: () => void;
47
47
  stop: () => void;
48
+ /**
49
+ * Stop, but send what is still owed first.
50
+ *
51
+ * `stop()` cancels the pending timer, and the last thing a session writes is the run artifact the
52
+ * whole drive produced — so a daemon asked to close moments after a verdict dropped the one push
53
+ * anybody was waiting on. Measured against app.reticle.sh: a drive wrote its run, the daemon
54
+ * exited, and the dashboard's newest row stayed three weeks old.
55
+ *
56
+ * Bounded, because shutdown is not allowed to hang: an in-flight cycle is waited out only until
57
+ * the deadline, and a cycle that cannot finish in time is simply the interval's problem next
58
+ * time — the artifact is still on disk and the next daemon for this project sends it.
59
+ */
60
+ flush: () => Promise<void>;
48
61
  }
49
62
  /**
50
63
  * Start the loop. Safe to call for an UNLINKED project: it resolves the link on every tick and does
@@ -54,6 +54,15 @@ const FIRST_CYCLE_DELAY_MS = 5_000;
54
54
  * written just after, so cycling on the very first byte would ship a bundle that is missing them.
55
55
  */
56
56
  const NUDGE_DELAY_MS = 1_500;
57
+ /**
58
+ * How long a flush waits for a cycle already in flight before running its own.
59
+ *
60
+ * Shutdown budget, not a sync budget: every call inside a cycle already carries its own timeout, so
61
+ * this only bounds the WAIT. Generous enough for an ordinary cycle to finish, short enough that a
62
+ * stalled one cannot hold a daemon open.
63
+ */
64
+ const FLUSH_WAIT_MS = 3_000;
65
+ const FLUSH_POLL_MS = 25;
57
66
  const defaultRequest = async (url, init) => {
58
67
  const res = await fetch(url, init);
59
68
  return { status: res.status, text: await res.text() };
@@ -270,6 +279,18 @@ export function startSyncDaemon(deps) {
270
279
  clearTimeout(timer);
271
280
  timer = undefined;
272
281
  },
282
+ flush: async () => {
283
+ stopped = true;
284
+ if (timer !== undefined)
285
+ clearTimeout(timer);
286
+ timer = undefined;
287
+ // A cycle already running may have started BEFORE the write being flushed, so its report says
288
+ // nothing about that artifact. Wait it out rather than skipping on the overlap guard.
289
+ const deadline = Date.now() + FLUSH_WAIT_MS;
290
+ while (running && Date.now() < deadline)
291
+ await new Promise((resolve) => setTimeout(resolve, FLUSH_POLL_MS));
292
+ await cycle();
293
+ },
273
294
  };
274
295
  }
275
296
  /**
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync, } from 'node:fs';
14
14
  import { dirname, join } from 'node:path';
15
- import { ReticleDir } from '@reticlehq/core';
15
+ import { IntentDir, ReticleDir } from '@reticlehq/core';
16
16
  import { subjectFor } from '../intent/intent-subject.js';
17
17
  const JSON_SUFFIX = '.json';
18
18
  /** Read + parse, or undefined for absent/unreadable/malformed. Never throws. */
@@ -73,8 +73,8 @@ const DERIVED_FILE = {
73
73
  intent: ReticleDir.INTENT_FILE,
74
74
  };
75
75
  /** The directory the sharded intent store writes into, beside the legacy flat file. */
76
- const INTENT_SUBDIR = 'intent';
77
- const INTENT_INDEX = 'index.json';
76
+ const INTENT_SUBDIR = ReticleDir.INTENT_SUBDIR;
77
+ const INTENT_INDEX = IntentDir.INDEX_FILE;
78
78
  /**
79
79
  * Every intent this project holds, flat file and shards merged.
80
80
  *
@@ -98,10 +98,15 @@ function readIntent(reticleRoot) {
98
98
  ...(legacy?.intents ?? {}),
99
99
  };
100
100
  let shards = 0;
101
- for (const file of safeReaddir(join(reticleRoot, INTENT_SUBDIR))) {
102
- if (!file.endsWith(JSON_SUFFIX) || INTENT_INDEX === file)
101
+ for (const entry of safeReaddir(join(reticleRoot, INTENT_SUBDIR))) {
102
+ if (INTENT_INDEX === entry)
103
103
  continue;
104
- const shard = readJson(join(reticleRoot, INTENT_SUBDIR, file));
104
+ // `<subject>/intent.json` is the layout; a bare `<subject>.json` is an interim shard the store
105
+ // has not migrated yet, and is still this project's memory until it has.
106
+ const path = entry.endsWith(JSON_SUFFIX)
107
+ ? join(reticleRoot, INTENT_SUBDIR, entry)
108
+ : join(reticleRoot, INTENT_SUBDIR, entry, IntentDir.SHARD_FILE);
109
+ const shard = readJson(path);
105
110
  // A hand-edited or half-written shard costs one subject, never the whole sync.
106
111
  if (shard?.intents === undefined)
107
112
  continue;
@@ -221,7 +221,13 @@ export class ImpactStore {
221
221
  if (this.#dashboardUrl !== undefined)
222
222
  snap.dashboardUrl = this.#dashboardUrl;
223
223
  snap.account = this.#account();
224
+ const cfgForOffer = this.#config.read();
224
225
  const offer = this.#offer.read();
226
+ // Joined here because this is the one place that holds BOTH the offer and the platform config.
227
+ // The card reads the offer, so readiness has to travel on it rather than being re-derived in a
228
+ // presenter that has no business knowing what a provider key is.
229
+ if (offer !== undefined && cfgForOffer !== undefined)
230
+ offer.drivable = cfgForOffer.providerReady;
225
231
  // Absent means "we have not heard", which the HUD renders as nothing at all. Never defaulted to
226
232
  // `{claimed:false}`: that would advertise the offer to everyone who is offline.
227
233
  if (offer !== undefined)
@@ -9,7 +9,7 @@ import { sessionRoot } from '../project/session-root.js';
9
9
  * and it does. `reticle_act_and_wait` and `reticle_assert` are in every agent's tool list already,
10
10
  * so one optional argument there makes the declaration free and discoverable by construction.
11
11
  *
12
- * This is a shortcut into the EXISTING ledger, never a second one. It writes `.reticle/intent.json`
12
+ * This is a shortcut into the EXISTING ledger, never a second one. It writes `.reticle/intent/`
13
13
  * through `IntentStore` exactly as `reticle_intent { action: "declare" }` does, so a reviewer reads
14
14
  * one file in one vocabulary and cannot tell from the row which door the intent came in by. The
15
15
  * pattern is `flows/flow-intent.ts`, which does the same job for a saved flow.
@@ -146,8 +146,13 @@ binding) {
146
146
  // Declared WITHOUT a surface on purpose: see dischargeInlineIntent for why the route that
147
147
  // describes this record only exists after the action it is about.
148
148
  const declaredHere = existing === undefined;
149
- if (declaredHere)
150
- await store.declare([{ id, statement: intent }]);
149
+ if (declaredHere) {
150
+ // Nothing stored (a step label is refused) means nothing to link: no verdict may then claim
151
+ // to have proved an intent that was never written.
152
+ const stored = await store.declare([{ id, statement: intent }]);
153
+ if (0 === stored.length)
154
+ return undefined;
155
+ }
151
156
  /*
152
157
  * A binding may be INVENTED only for a row this call also declared.
153
158
  *
@@ -1,29 +1,31 @@
1
1
  /**
2
- * Reading and writing the sharded intent store, and migrating the flat one into it.
2
+ * The intent ledger on disk: one directory per subject, and a flow's name IS its subject.
3
3
  *
4
- * The operations an agent actually performs, and what each is allowed to cost:
4
+ * .reticle/intent/index.json every id, its subject and one line — derived
5
+ * .reticle/intent/<subject>/intent.json the full records for that subject
5
6
  *
6
- * index() every id + one line, always affordable — the session-start read
7
- * subject(name) the full records for one subject, when it is about to work on that
8
- * get(id) one record, via the index rather than by scanning shards
9
- * record()/update() a write that touches ONE shard file plus the index
7
+ * The ONE place intents are stored. `IntentStore` is the domain API over it (declare, bind,
8
+ * discharge) and never writes a file of its own; `reticle_intent`'s record/get/index/subject read
9
+ * this directly. Two stores kept in two files is how an intent written through one action became
10
+ * invisible to the other, and an agent told its own intent does not exist concludes it does not.
10
11
  *
11
- * The last one is the reason for the whole design. Under the flat file, changing one intent rewrote
12
- * all 141, so two sessions touching unrelated subjects raced each other and the loser's write
13
- * vanished. Sharded, they only collide when they genuinely edit the same subject.
12
+ * Git-checked on purpose: a human sees in review when an intent is narrowed to match what was easy
13
+ * to prove. Writes are byte-stable, and a write that changes nothing writes nothing, because a ledger
14
+ * that churns on every run is one whose diffs nobody reads.
14
15
  *
15
- * ## Migration is automatic, and it does not delete
16
+ * ## Migration happens on the first write, and it removes what it moved
16
17
  *
17
- * `.reticle/intent.json` is read on every load and folded in when the sharded copy has nothing for
18
- * an id. That makes the move invisible: an agent on the new store sees old intents immediately, and
19
- * an older build still reading the flat file keeps working, because nothing removes it. Deleting it
20
- * is a separate decision somebody can take once they believe the migration.
18
+ * The old single `.reticle/intent.json` (and any interim `intent/<subject>.json`) is read until then,
19
+ * so nothing is invisible before the move. The first write folds every record into its directory and
20
+ * deletes the old files, so the change shows up in one reviewable diff. A legacy file that does not
21
+ * PARSE is never deleted: a hand-merge leaves conflict markers, it reads as empty, and removing it
22
+ * would destroy the only copy of every intent inside.
21
23
  */
22
24
  import { type Intent } from '@reticlehq/core/artifacts';
23
25
  import type { FileSystemPort } from '../project/fs/fs-port.js';
24
26
  import type { Clock } from '../../machine/clock.js';
25
27
  import { IntentStatus, type IntentIndex, type IntentRecord } from './intent-shard.js';
26
- /** What a caller supplies to write an intent. Everything optional is genuinely optional. */
28
+ /** What a caller supplies to write an intent through `record`. Everything optional is optional. */
27
29
  interface IntentInput {
28
30
  id: string;
29
31
  statement: string;
@@ -34,51 +36,35 @@ interface IntentInput {
34
36
  surface?: Intent['surface'];
35
37
  binding?: unknown;
36
38
  }
39
+ /** The ledger-level facts a write may set beside the intent itself. */
40
+ export interface RecordFacts {
41
+ subject?: string | undefined;
42
+ status?: IntentStatus | undefined;
43
+ why?: string | undefined;
44
+ source?: string | undefined;
45
+ }
37
46
  export declare class IntentShardStore {
38
47
  #private;
39
48
  constructor(fs: FileSystemPort, root: string, clock: Clock);
40
- /**
41
- * Every record: the shards, plus any legacy intent the shards have not absorbed.
42
- *
43
- * Sharded wins on a collision. An id present in both has been migrated and possibly edited since,
44
- * and letting the flat copy overwrite that would silently revert the edit.
45
- */
46
- /**
47
- * Every record this ledger holds, from BOTH files, deduped by id.
48
- *
49
- * Public because the ledger is one logical thing kept in two places, and the tool that offers
50
- * `list` has to be able to see all of it. It was private while `record` wrote to shards and `list`
51
- * read the flat file, which meant an intent written through one action was invisible to the other
52
- * — and an agent told its own recorded intent does not exist concludes the intent does not exist,
53
- * not that there are two stores.
54
- */
49
+ /** Every record: the directories, plus any legacy intent not yet moved. Directories win. */
55
50
  all(): Promise<IntentRecord[]>;
56
- /**
57
- * The cheap read: one line per intent, and the call an agent makes at the start of a session.
58
- *
59
- * Includes anything still only in the legacy flat file, so the very first call on an unmigrated
60
- * project already answers completely — which is what makes the migration invisible rather than a
61
- * step somebody has to remember to run.
62
- */
51
+ /** The cheap read: one line per intent. Derived from disk, never from the stored copy. */
63
52
  index(): Promise<IntentIndex>;
64
53
  /** Every record for one subject — the working read, once an agent knows what it is touching. */
65
54
  subject(name: string): Promise<IntentRecord[]>;
66
55
  /** One record by id, or null. */
67
56
  get(id: string): Promise<IntentRecord | null>;
68
57
  /**
69
- * Write one intent, creating or updating it.
58
+ * Create or change ONE intent atomically: read, transform, write, under the ledger's lock.
70
59
  *
71
- * Merges onto whatever is already stored, so a caller adding a `why` to a migrated record does not
72
- * have to restate its binding — and cannot silently drop it by omission, which is the failure mode
73
- * that makes agents afraid to touch a store.
60
+ * `transform` returns the next intent, or undefined for "leave it as it is". A result equal to
61
+ * what is stored writes nothing. Returns the stored record, or undefined when nothing was there
62
+ * and nothing was created.
74
63
  */
64
+ upsert(id: string, transform: (existing: IntentRecord | undefined) => Intent | undefined, facts?: RecordFacts): Promise<IntentRecord | undefined>;
65
+ /** Write one intent through `reticle_intent { action: "record" }`, merging onto what is stored. */
75
66
  record(input: IntentInput): Promise<IntentRecord>;
76
- /**
77
- * Fold the flat file into shards on disk, so the move is visible in a diff rather than implicit.
78
- *
79
- * Idempotent, and it does not delete `.reticle/intent.json`: an older build still reads it, and a
80
- * migration that removes its own source cannot be checked afterwards.
81
- */
67
+ /** `reticle_intent { action: "migrate" }`: do now what the next write would do anyway. */
82
68
  migrate(): Promise<{
83
69
  migrated: number;
84
70
  subjects: string[];