@reticlehq/server 2.10.0 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/SKILL.md +47 -9
  2. package/dist/dev/stale-issue-guard.d.ts +43 -0
  3. package/dist/dev/stale-issue-guard.js +75 -0
  4. package/dist/domain/domain-tools.js +3 -2
  5. package/dist/events/predicate-asks.d.ts +17 -0
  6. package/dist/events/predicate-asks.js +26 -0
  7. package/dist/events/predicate-eval.js +211 -24
  8. package/dist/events/predicate-parse.js +9 -3
  9. package/dist/events/predicate-schema.d.ts +18 -0
  10. package/dist/events/predicate-schema.js +55 -0
  11. package/dist/flows/flow-tools.js +33 -4
  12. package/dist/flows/predicate-to-expect.js +8 -0
  13. package/dist/flows/verify-change-tools.js +5 -1
  14. package/dist/honesty/blind-spots.d.ts +9 -0
  15. package/dist/honesty/blind-spots.js +17 -4
  16. package/dist/honesty/gap-ledger.d.ts +52 -0
  17. package/dist/honesty/gap-ledger.js +63 -0
  18. package/dist/honesty/instrumentation-gaps.d.ts +55 -0
  19. package/dist/honesty/instrumentation-gaps.js +21 -0
  20. package/dist/honesty/observability.d.ts +37 -0
  21. package/dist/honesty/observability.js +40 -0
  22. package/dist/index.d.ts +0 -1
  23. package/dist/index.js +41 -0
  24. package/dist/init/node-io.js +3 -0
  25. package/dist/init/run.d.ts +18 -0
  26. package/dist/init/run.js +34 -3
  27. package/dist/init/slash-command.d.ts +1 -1
  28. package/dist/init/slash-command.js +33 -10
  29. package/dist/journal/envelope.d.ts +24 -24
  30. package/dist/license/license.d.ts +8 -2
  31. package/dist/license/license.js +10 -15
  32. package/dist/project/artifact-root.d.ts +83 -0
  33. package/dist/project/artifact-root.js +112 -0
  34. package/dist/project/memory-fs.d.ts +9 -0
  35. package/dist/project/memory-fs.js +88 -0
  36. package/dist/project/project-store.d.ts +17 -0
  37. package/dist/project/project-store.js +33 -0
  38. package/dist/project/remember-project.d.ts +24 -0
  39. package/dist/project/remember-project.js +40 -0
  40. package/dist/project/session-root.d.ts +15 -0
  41. package/dist/project/session-root.js +31 -0
  42. package/dist/runs/run-tools.js +2 -1
  43. package/dist/session/lease-availability.d.ts +40 -0
  44. package/dist/session/lease-availability.js +43 -0
  45. package/dist/session/no-session-diagnosis.d.ts +10 -0
  46. package/dist/session/no-session-diagnosis.js +19 -7
  47. package/dist/session/no-session-watch.js +1 -1
  48. package/dist/session/session.d.ts +9 -0
  49. package/dist/session/session.js +9 -0
  50. package/dist/session/stall-clock.d.ts +18 -0
  51. package/dist/session/stall-clock.js +48 -0
  52. package/dist/telemetry/daemon-telemetry.js +12 -23
  53. package/dist/telemetry/session-metrics.d.ts +1 -1
  54. package/dist/telemetry/session-metrics.js +1 -1
  55. package/dist/telemetry/telemetry.d.ts +12 -3
  56. package/dist/telemetry/telemetry.js +25 -5
  57. package/dist/tools/act-tools.js +34 -4
  58. package/dist/tools/assert-verdict.d.ts +2 -0
  59. package/dist/tools/assert-verdict.js +24 -1
  60. package/dist/tools/contract-tools.js +5 -3
  61. package/dist/tools/coverage-tools.js +33 -0
  62. package/dist/tools/observe-tools.js +8 -2
  63. package/dist/tools/tool-kit.d.ts +16 -1
  64. package/docs/README.md +1 -0
  65. package/docs/docs.json +2 -2
  66. package/docs/enterprise.md +3 -1
  67. package/docs/license-activation.mdx +145 -0
  68. package/docs/telemetry-contract.md +1 -12
  69. package/docs/telemetry-events.mdx +1 -34
  70. package/docs/telemetry.md +0 -1
  71. package/package.json +2 -2
  72. package/dist/telemetry/instrumentation-stall.d.ts +0 -57
  73. package/dist/telemetry/instrumentation-stall.js +0 -103
@@ -21,6 +21,10 @@ const PREDICATE_ALIASES = {
21
21
  // most common thing an agent reaches for here.
22
22
  [PredicateKind.ROUTE]: { path: 'pathname', urlContains: 'contains', url: 'contains' },
23
23
  [PredicateKind.NET]: { url: 'urlContains' },
24
+ // `textContains` on a `console` predicate: the reporter who hit the missing matcher reached for
25
+ // it first, by analogy with `net { urlContains }` — and that parallel is exactly right, since
26
+ // both mean "this entry must contain this substring". Accepted as an alias for `contains`.
27
+ [PredicateKind.CONSOLE]: { textContains: 'contains' },
24
28
  [PredicateKind.SIGNAL]: { data: 'dataMatches' },
25
29
  // `of` on a composite: it reads naturally, several assertion libraries spell it that way, and it has
26
30
  // no other meaning here. Observed twice in one drive on a real app, and each rejection cost a round
@@ -246,6 +250,7 @@ function predicateUnion() {
246
250
  .object({
247
251
  kind: z.literal(PredicateKind.CONSOLE),
248
252
  level: z.string().optional(),
253
+ contains: z.string().min(1).optional(),
249
254
  absent: z.boolean().optional(),
250
255
  since: z.number().optional(),
251
256
  })
@@ -307,3 +312,53 @@ export function predicateFieldsFor(kind) {
307
312
  }
308
313
  return [];
309
314
  }
315
+ /** Peel optional/nullable/default/effects wrappers off a field to reach the schema underneath. */
316
+ function unwrapSchema(schema) {
317
+ let current = schema;
318
+ for (;;) {
319
+ if (current instanceof z.ZodOptional ||
320
+ current instanceof z.ZodNullable ||
321
+ current instanceof z.ZodDefault) {
322
+ current = current._def.innerType;
323
+ continue;
324
+ }
325
+ if (current instanceof z.ZodEffects) {
326
+ current = current._def.schema;
327
+ continue;
328
+ }
329
+ return current;
330
+ }
331
+ }
332
+ /**
333
+ * One level into the fields that are themselves objects — `{ query: ['by', 'value', ...] }`.
334
+ *
335
+ * Naming the top-level fields alone leaves `element` unguessable: `query` is an object with its own
336
+ * required shape, and a plain CSS string works in `reticle_snapshot.scope` and `reticle_query.scope`,
337
+ * so assuming it works here is the natural guess. The rejection is the only place that inconsistency
338
+ * can be explained.
339
+ *
340
+ * One level only. Derived from the schema for the same reason `predicateFieldsFor` is: a stale field
341
+ * list is worse than none, because the agent trusts it and retries into the same wall.
342
+ */
343
+ export function predicateNestedFieldsFor(kind) {
344
+ for (const option of predicateUnion().options) {
345
+ const literal = option.shape['kind'];
346
+ if (!(literal instanceof z.ZodLiteral) || literal.value !== kind)
347
+ continue;
348
+ const nested = {};
349
+ for (const [field, schema] of Object.entries(option.shape)) {
350
+ if ('kind' === field)
351
+ continue;
352
+ const inner = unwrapSchema(schema);
353
+ // `instanceof z.ZodObject` narrows to ZodObject<any>, whose `.shape` is `any`. Name the shape
354
+ // type so the keys are read off something typed rather than laundering an `any` through
355
+ // Object.keys.
356
+ if (inner instanceof z.ZodObject) {
357
+ const shape = inner.shape;
358
+ nested[field] = Object.keys(shape);
359
+ }
360
+ }
361
+ return nested;
362
+ }
363
+ return {};
364
+ }
@@ -14,6 +14,7 @@ import { resolveProjectCloud } from '../cloud/cloud-config.js';
14
14
  import { buildSuiteVerdict } from './decision.js';
15
15
  import { classifyFlowAssertions } from './flow-classify.js';
16
16
  import { isValidFlowName, flowPath } from '../project/reticle-dir.js';
17
+ import { FlowStore } from './flows.js';
17
18
  import { replayNamedFlow, flowErrorMessage, latestRecordedFlow, sessionProjectId, } from './flow-replay-run.js';
18
19
  import { persistAndSyncVerificationRun } from '../runs/verification-sync.js';
19
20
  import { runServerVerify } from './server-verify.js';
@@ -67,6 +68,24 @@ export function leaseFailureReplay(name, error) {
67
68
  }
68
69
  /** Stand-in path for a flow whose name fails the path-segment guard — never a real join. */
69
70
  const INVALID_FLOW_NAME_PATH = '(invalid flow name — not a usable path)';
71
+ /**
72
+ * The flow store a SESSION's artifacts belong in, and the root it resolved to.
73
+ *
74
+ * `deps.flows` is bound to the daemon's own root at construction, which is right only when the
75
+ * daemon happens to have been started in the project it is driving. When a resolver is wired and it
76
+ * names a different project, the write has to go there instead — so the store is rebound for this
77
+ * call. Rebinding is cheap: a FlowStore is a filesystem port, a root and a clock.
78
+ *
79
+ * Returns `deps.flows` unchanged when nothing is wired or the roots already agree, so the common
80
+ * case allocates nothing and behaves exactly as before.
81
+ */
82
+ function flowsForSession(deps, projectId) {
83
+ const resolved = deps.artifactRootFor?.(projectId);
84
+ if (resolved === undefined || resolved.root === deps.reticleRoot) {
85
+ return { flows: deps.flows, root: deps.reticleRoot };
86
+ }
87
+ return { flows: new FlowStore(deps.fs, resolved.root, { now: deps.now }), root: resolved.root };
88
+ }
70
89
  export const FLOW_TOOLS = [
71
90
  {
72
91
  name: ReticleTool.FLOW_SAVE,
@@ -132,16 +151,26 @@ export const FLOW_TOOLS = [
132
151
  const emptyRefusal = emptyFlowRefusal(program.steps.length, name);
133
152
  if (emptyRefusal !== undefined)
134
153
  return Promise.resolve(emptyRefusal);
135
- return deps.flows.save(program, annotations, projectId).then(async (res) => {
154
+ const { flows, root } = flowsForSession(deps, projectId);
155
+ return flows.save(program, annotations, projectId).then(async (res) => {
136
156
  if (!res.ok)
137
157
  return { error: flowErrorMessage(res.code), code: res.code };
138
158
  deps.annotations.clear(name);
139
159
  // Grade the saved flow's assertions so the agent learns immediately if it just saved a flow
140
160
  // that asserts nothing observable (passes even when the feature is broken).
141
- const loaded = await deps.flows.load(res.value.name, projectId);
161
+ const loaded = await flows.load(res.value.name, projectId);
162
+ // The absolute path, always. A save that reports success and not WHERE cost a reporter a
163
+ // `find` across their disk to discover it had landed in an unrelated repo. The name came
164
+ // back from a save that succeeded, so the guard is a type narrowing rather than a doubt.
165
+ const saved = res.value.name;
166
+ const path = isValidFlowName(saved) ? flowPath(root, saved, projectId) : undefined;
142
167
  return loaded.ok
143
- ? { ...res.value, assertions: classifyFlowAssertions(loaded.value) }
144
- : res.value;
168
+ ? {
169
+ ...res.value,
170
+ ...(path === undefined ? {} : { path }),
171
+ assertions: classifyFlowAssertions(loaded.value),
172
+ }
173
+ : { ...res.value, ...(path === undefined ? {} : { path }) };
145
174
  });
146
175
  },
147
176
  },
@@ -25,6 +25,14 @@ export function predicateToExpect(predicate) {
25
25
  return 0 === Object.keys(net).length ? undefined : { net };
26
26
  }
27
27
  case PredicateKind.CONSOLE: {
28
+ // `contains` has no representation in FlowExpect.console (core keeps `level` and `absent`
29
+ // and nothing else). Copying the rest through anyway would record an assertion the agent
30
+ // never made: {console, level:'warn', contains:'no-op', absent:true} would save "no warn
31
+ // entries at all", a false red on any unrelated warning, and in the presence direction it
32
+ // saves a strictly weaker claim than the one chosen. This file's own rule applies: record
33
+ // nothing rather than something different, so the flow stays assertion-free there instead.
34
+ if (predicate.contains !== undefined)
35
+ return undefined;
28
36
  const console_ = {};
29
37
  if (predicate.level !== undefined)
30
38
  console_.level = predicate.level;
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { sessionRoot } from '../project/session-root.js';
2
3
  import { verdictForSuite } from './verify-change-verdict.js';
3
4
  import { attributedFailures } from './attributed-failure.js';
4
5
  import { Verified } from '@reticlehq/core';
@@ -62,7 +63,10 @@ export const VERIFY_CHANGE_TOOLS = [
62
63
  : [];
63
64
  const since = asString(args['since']);
64
65
  const changedFiles = await resolveChangedFiles(files, since);
65
- const flows = await loadNamedFlows(deps.fs, deps.reticleRoot);
66
+ // Same root flow_save wrote to. A read that resolved differently would report "no flows
67
+ // covered this change" over flows that exist, which reads as a clean result rather than
68
+ // an error, so the disagreement would be invisible.
69
+ const flows = await loadNamedFlows(deps.fs, sessionRoot(deps, asString(args['sessionId'])));
66
70
  const { affected, unknownProvenance } = affectedSavedFlows(flows, changedFiles);
67
71
  if (0 === changedFiles.length) {
68
72
  return {
@@ -84,3 +84,12 @@ export declare function blindSpotsFromState(state: Readonly<Record<string, numbe
84
84
  export declare function droppedByTransport(events: readonly ReticleEvent[]): number;
85
85
  /** The impeaching note for a transport gap, or undefined when the window was intact. */
86
86
  export declare function transportGapNote(events: readonly ReticleEvent[]): string | undefined;
87
+ /**
88
+ * Is the state channel dark?
89
+ *
90
+ * Nothing subscribed means NO state change is observed, so an empty `stateDiffs` is a statement
91
+ * about Reticle rather than about the app. Derived here, once, because two callers now need it —
92
+ * the act path's causal summary and the instrumentation-gap surface — and a second spelling of this
93
+ * predicate would eventually disagree with the first about what "unwatched" means.
94
+ */
95
+ export declare function isStateUnwatched(spots: readonly BlindSpot[]): boolean;
@@ -45,10 +45,12 @@ const LABEL = {
45
45
  // nothing here can support. Names the one-line fix, because the reader is the one who can apply it.
46
46
  [BlindSpotKind.UNWATCHED_STATE]: () => 'no subscribable store is registered, so NO state change is observed — an empty stateDiffs here means unwatched, not unchanged (register the store itself in your reticle-dev module: registerStore(name, store), not a getter)',
47
47
  [BlindSpotKind.WRAPPED_NETWORK]: () => 'fetch was already wrapped before Reticle, so recorded requests may differ from what was sent',
48
- // A fact about the app's wiring, not a tally. Says what the empty network view MEANS, because an
49
- // empty one here is indistinguishable from "this app made no backend calls" — and that reading is
50
- // the false green: every IPC call went unseen and every `assert { net }` over them is vacuous.
51
- [BlindSpotKind.UNOBSERVED_IPC]: () => "this Electron renderer has no Reticle preload, so NO ipcRenderer.invoke is observed — an empty network view here means unseen, not none (add require('@reticlehq/electron/preload') to your preload)",
48
+ // A fact about the app's wiring, not a tally. Says what a network view WITHOUT ipc:// records
49
+ // MEANS, because that state here is indistinguishable from "this app made no backend calls" —
50
+ // and that reading is the false green: every IPC call went unseen and every `assert { net }` over
51
+ // them is vacuous. Document-initiated loads ARE seen since subresource observation (#447), so
52
+ // the note names the missing half instead of claiming the whole view is blind.
53
+ [BlindSpotKind.UNOBSERVED_IPC]: () => "this Electron renderer has no Reticle preload, so NO ipcRenderer.invoke is observed; document-initiated loads may still appear, so a view with no ipc:// records means unseen IPC, not none (add require('@reticlehq/electron/preload') to your preload)",
52
54
  };
53
55
  /**
54
56
  * Blind spots that IMPEACH the capture, as opposed to bounding what it covers.
@@ -150,3 +152,14 @@ export function transportGapNote(events) {
150
152
  ? undefined
151
153
  : `the browser dropped ${String(dropped)} event(s) in this window (transport queue overflow), so events that would have contradicted this may never have arrived`;
152
154
  }
155
+ /**
156
+ * Is the state channel dark?
157
+ *
158
+ * Nothing subscribed means NO state change is observed, so an empty `stateDiffs` is a statement
159
+ * about Reticle rather than about the app. Derived here, once, because two callers now need it —
160
+ * the act path's causal summary and the instrumentation-gap surface — and a second spelling of this
161
+ * predicate would eventually disagree with the first about what "unwatched" means.
162
+ */
163
+ export function isStateUnwatched(spots) {
164
+ return spots.some((spot) => spot.kind === BlindSpotKind.UNWATCHED_STATE && spot.count > 0);
165
+ }
@@ -0,0 +1,52 @@
1
+ import { type InstrumentationGap } from '@reticlehq/core';
2
+ /**
3
+ * What this session still cannot see, accumulated across its verdicts.
4
+ *
5
+ * A gap reported on one verdict and then forgotten is a note. A gap that is still standing when the
6
+ * agent asks "am I done?" is a piece of work — and that difference is the entire discipline
7
+ * mechanism, because an agent finishing a task is the one moment it will act on a cost it can close.
8
+ *
9
+ * ## Why the LAST verdict decides, not the whole history
10
+ *
11
+ * The honest question is "is this app still unverifiable in this way", not "was it ever". An agent
12
+ * that hits a gap, adds the build plugin and re-verifies has CLOSED it — the later verdict simply
13
+ * stops emitting that kind — and a ledger that still reported it would punish exactly the behaviour
14
+ * this exists to cause. Nobody instruments an app twice for a tool that never notices the first time.
15
+ *
16
+ * So `open()` reflects the most recent verdict, and `everSeen` is kept separately for the summary,
17
+ * where "you hit this and fixed it" is worth saying and is not a reason to withhold done.
18
+ */
19
+ export declare class GapLedger {
20
+ #private;
21
+ /**
22
+ * Record what the verdict just reported. An empty list is a real observation — it is how a gap
23
+ * gets closed — so it must not be ignored as "nothing to record".
24
+ */
25
+ note(gaps: readonly InstrumentationGap[]): void;
26
+ /** Gaps the most recent verdict still reported. The ones that are work right now. */
27
+ open(): readonly InstrumentationGap[];
28
+ /** Every kind seen in this session, including ones since closed. */
29
+ get everSeen(): readonly string[];
30
+ /**
31
+ * Did this session leave the app less verifiable than it could be?
32
+ *
33
+ * The one question a "have I finished" call needs answered, and the one a session summary turns
34
+ * into `unproven` rather than `verified`.
35
+ */
36
+ get hasOpen(): boolean;
37
+ }
38
+ /**
39
+ * Record a verdict's gaps against the session that produced it.
40
+ *
41
+ * Tolerates a session with no ledger, and that is a statement about the seam rather than a hedge:
42
+ * `Session` is satisfied structurally, and dozens of specs — plus any consumer embedding this engine
43
+ * — build one as an object literal rather than through the constructor. Such a session is a real
44
+ * caller, it simply keeps no ledger, and recording is a side effect that must never be the reason a
45
+ * verdict fails to return.
46
+ *
47
+ * One helper rather than an optional-chain at each call site: the tolerance is a decision, and a
48
+ * decision spelled `?.` in two places is one nobody can find later.
49
+ */
50
+ export declare function noteSessionGaps(session: {
51
+ gaps?: GapLedger;
52
+ }, gaps: readonly InstrumentationGap[]): void;
@@ -0,0 +1,63 @@
1
+ import { dedupeGaps } from '@reticlehq/core';
2
+ /**
3
+ * What this session still cannot see, accumulated across its verdicts.
4
+ *
5
+ * A gap reported on one verdict and then forgotten is a note. A gap that is still standing when the
6
+ * agent asks "am I done?" is a piece of work — and that difference is the entire discipline
7
+ * mechanism, because an agent finishing a task is the one moment it will act on a cost it can close.
8
+ *
9
+ * ## Why the LAST verdict decides, not the whole history
10
+ *
11
+ * The honest question is "is this app still unverifiable in this way", not "was it ever". An agent
12
+ * that hits a gap, adds the build plugin and re-verifies has CLOSED it — the later verdict simply
13
+ * stops emitting that kind — and a ledger that still reported it would punish exactly the behaviour
14
+ * this exists to cause. Nobody instruments an app twice for a tool that never notices the first time.
15
+ *
16
+ * So `open()` reflects the most recent verdict, and `everSeen` is kept separately for the summary,
17
+ * where "you hit this and fixed it" is worth saying and is not a reason to withhold done.
18
+ */
19
+ export class GapLedger {
20
+ #open = [];
21
+ #everSeen = new Set();
22
+ /**
23
+ * Record what the verdict just reported. An empty list is a real observation — it is how a gap
24
+ * gets closed — so it must not be ignored as "nothing to record".
25
+ */
26
+ note(gaps) {
27
+ this.#open = dedupeGaps(gaps);
28
+ for (const gap of gaps)
29
+ this.#everSeen.add(gap.kind);
30
+ }
31
+ /** Gaps the most recent verdict still reported. The ones that are work right now. */
32
+ open() {
33
+ return this.#open;
34
+ }
35
+ /** Every kind seen in this session, including ones since closed. */
36
+ get everSeen() {
37
+ return [...this.#everSeen];
38
+ }
39
+ /**
40
+ * Did this session leave the app less verifiable than it could be?
41
+ *
42
+ * The one question a "have I finished" call needs answered, and the one a session summary turns
43
+ * into `unproven` rather than `verified`.
44
+ */
45
+ get hasOpen() {
46
+ return this.#open.length > 0;
47
+ }
48
+ }
49
+ /**
50
+ * Record a verdict's gaps against the session that produced it.
51
+ *
52
+ * Tolerates a session with no ledger, and that is a statement about the seam rather than a hedge:
53
+ * `Session` is satisfied structurally, and dozens of specs — plus any consumer embedding this engine
54
+ * — build one as an object literal rather than through the constructor. Such a session is a real
55
+ * caller, it simply keeps no ledger, and recording is a side effect that must never be the reason a
56
+ * verdict fails to return.
57
+ *
58
+ * One helper rather than an optional-chain at each call site: the tolerance is a decision, and a
59
+ * decision spelled `?.` in two places is one nobody can find later.
60
+ */
61
+ export function noteSessionGaps(session, gaps) {
62
+ session.gaps?.note(gaps);
63
+ }
@@ -0,0 +1,55 @@
1
+ import { type InstrumentationGap } from '@reticlehq/core';
2
+ /**
3
+ * The gaps ONE action revealed, derived from facts the act path already holds.
4
+ *
5
+ * Pure, and deliberately fed by primitives rather than by the action result: every fact below is
6
+ * already computed for other reasons — `actedSource` for the red-verdict file:line, `stateUnwatched`
7
+ * because an empty `stateDiffs` would otherwise read as "the app changed nothing", the signal count
8
+ * and route movement for the causal summary. Nothing here costs a new observation. The gap surface
9
+ * is a second reading of evidence Reticle was already collecting and then discarding.
10
+ *
11
+ * ## Each kind carries its OWN gate
12
+ *
13
+ * The rule is that a gap fires only when the verdict came back weaker because of it — and "weaker"
14
+ * is different per kind, so a single global flag would be a lie in both directions:
15
+ *
16
+ * - a missing source mapping costs nothing on a PASS. The act path already says so in as many
17
+ * words: on green nobody needs the file:line and it is noise. It costs a great deal on a red,
18
+ * where it is the first thing the agent wants;
19
+ * - a missing store costs nothing unless the caller ASKED about state;
20
+ * - a silent mutation costs nothing if the app proved the outcome some other way.
21
+ *
22
+ * Encoding the gates per kind is what keeps this from degenerating into a linter that fires on every
23
+ * uninstrumented control on the page. That surface would be ignored within a day, and it would take
24
+ * the true positives with it.
25
+ */
26
+ export interface ActionInstrumentationFacts {
27
+ /** Did the verdict pass? Several gates turn on this, in different directions. */
28
+ pass: boolean;
29
+ /** True when the outcome was positively PROVED rather than inferred. */
30
+ proved?: boolean;
31
+ /**
32
+ * Does Reticle know a `file:line` for what this verdict is about?
33
+ *
34
+ * A boolean rather than the source itself, deliberately: the act path holds a `{file, line}` and
35
+ * the assert path holds a formatted string it remembered from the last act. The gap does not care
36
+ * which — only whether the agent can be pointed at code — and taking the shape would make one of
37
+ * the two callers convert for no reason.
38
+ */
39
+ sourceKnown: boolean;
40
+ /** The ref that was driven, for the report. */
41
+ ref?: string | undefined;
42
+ /** Did the caller's predicate ask about registered state? */
43
+ stateAsked: boolean;
44
+ /** True when the session has no store registered to read. */
45
+ stateUnwatched: boolean;
46
+ /** Did the DOM move in this action's window? */
47
+ domMutated: boolean;
48
+ /** How many app signals fired in the window. */
49
+ signalsFired: number;
50
+ /** Did the route change in this action's window? */
51
+ routeChanged: boolean;
52
+ /** Did anything signal that route change? */
53
+ routeSignalFired: boolean;
54
+ }
55
+ export declare function gapsForAction(facts: ActionInstrumentationFacts): InstrumentationGap[];
@@ -0,0 +1,21 @@
1
+ import { InstrumentationGapKind, dedupeGaps, instrumentationGap, } from '@reticlehq/core';
2
+ export function gapsForAction(facts) {
3
+ const gaps = [];
4
+ // A red verdict names the control and cannot name the line that renders it. That is the round trip
5
+ // the agent is about to spend, and the one a build plugin removes permanently.
6
+ if (!facts.pass && !facts.sourceKnown) {
7
+ gaps.push(instrumentationGap(InstrumentationGapKind.NO_SOURCE_MAPPING, 'the control that was driven carries no source mapping', 'this verdict can name the control but not the file and line that render it, so finding the code is a separate search', { ...(facts.ref === undefined ? {} : { ref: facts.ref }) }));
8
+ }
9
+ // Asked about state, and there is no state channel to answer from.
10
+ if (facts.stateAsked && facts.stateUnwatched) {
11
+ gaps.push(instrumentationGap(InstrumentationGapKind.NO_STORE_REGISTERED, 'no store is registered, and this assertion was about state', 'the assertion could not be answered from the deterministic channel and had to fall back to what the DOM happens to show'));
12
+ }
13
+ // The app moved and said nothing, and Reticle could not prove the outcome another way.
14
+ if (facts.domMutated && 0 === facts.signalsFired && true !== facts.proved) {
15
+ gaps.push(instrumentationGap(InstrumentationGapKind.NO_SIGNAL_ON_MUTATION, 'the DOM changed and no signal fired for it', 'the outcome had to be inferred from the DOM instead of read from the app asserting its own success, which is the strongest evidence available', { ...(facts.ref === undefined ? {} : { ref: facts.ref }) }));
16
+ }
17
+ if (facts.routeChanged && !facts.routeSignalFired) {
18
+ gaps.push(instrumentationGap(InstrumentationGapKind.NO_ROUTE_SIGNAL, 'the route changed and nothing signalled it', 'route consequences cannot be asserted on this app, so a navigation can only be checked by what rendered afterwards'));
19
+ }
20
+ return dedupeGaps(gaps);
21
+ }
@@ -0,0 +1,37 @@
1
+ import type { InstrumentationGap } from '@reticlehq/core';
2
+ /**
3
+ * Of the controls this session drove, how many could Reticle fully observe?
4
+ *
5
+ * The first honest denominator this product has ever had. `reticle_verify{action:"coverage"}`
6
+ * already answers "which controls did you not drive"; this answers the question underneath it —
7
+ * *of the ones you did drive, how many could the app actually answer for?* A high untouched-count is
8
+ * work remaining for the agent. A low observability is work remaining in the APP, and no amount of
9
+ * driving closes it.
10
+ *
11
+ * ## Two things it deliberately refuses to do
12
+ *
13
+ * **It does not report a percentage for an empty run.** 0/0 rendered as 100% is the most flattering
14
+ * possible reading of no evidence at all, and a coverage number that starts perfect is one nobody
15
+ * believes the first time it drops.
16
+ *
17
+ * **It does not count app-level gaps against a control.** An unregistered store or an unsignalled
18
+ * route is missing from the application, not from a button. Attributing it to whichever control
19
+ * happened to be driven would put the number on the wrong thing and make it move for reasons the
20
+ * agent cannot act on. Those gaps are reported alongside, not divided into.
21
+ */
22
+ export interface Observability {
23
+ /** Distinct controls driven this session. */
24
+ driven: number;
25
+ /** How many of them Reticle could fully observe. */
26
+ observable: number;
27
+ /** Rounded percentage. OMITTED when nothing was driven — see above. */
28
+ percent?: number;
29
+ }
30
+ export declare function observabilityOf(drivenRefs: Iterable<string>, gaps: readonly InstrumentationGap[]): Observability;
31
+ export interface CoverageRegression {
32
+ was: number;
33
+ now: number;
34
+ }
35
+ export declare function coverageRegressed(best: {
36
+ percent: number;
37
+ } | undefined, current: Observability): CoverageRegression | undefined;
@@ -0,0 +1,40 @@
1
+ export function observabilityOf(
2
+ // Iterable, not an array: the session holds these as a Set, and making the caller spread it would
3
+ // be a conversion that exists only to satisfy a signature.
4
+ drivenRefs, gaps) {
5
+ const driven = new Set(drivenRefs);
6
+ const impaired = new Set();
7
+ for (const gap of gaps) {
8
+ // App-level gaps carry no ref, and a gap on a control this session never drove is about some
9
+ // earlier route — neither belongs in a ratio over what WAS driven.
10
+ if (gap.ref !== undefined && driven.has(gap.ref))
11
+ impaired.add(gap.ref);
12
+ }
13
+ const observable = driven.size - impaired.size;
14
+ if (0 === driven.size)
15
+ return { driven: 0, observable: 0 };
16
+ return { driven: driven.size, observable, percent: Math.round((observable / driven.size) * 100) };
17
+ }
18
+ /**
19
+ * The floor under the number, shipped with it rather than after it.
20
+ *
21
+ * A coverage figure with no downgrade check is a figure that gets gamed, and we would be the ones
22
+ * teaching agents to game it: the cheapest way to stop a gap firing is to stop asserting the thing
23
+ * that revealed it. The same reasoning already runs for flow assertions in `assertion-tiers-store`,
24
+ * where a flow that passes more weakly than it used to is a finding rather than a pass.
25
+ *
26
+ * Returns undefined — not a false — when there is nothing honest to compare. A first run has no
27
+ * best to fall from, and a run that drove almost nothing is evidence of a short session rather than
28
+ * of a regression.
29
+ */
30
+ /** Below this, a run is too small for its ratio to mean anything. */
31
+ const MIN_DRIVEN_TO_COMPARE = 3;
32
+ export function coverageRegressed(best, current) {
33
+ if (best === undefined)
34
+ return undefined;
35
+ if (current.percent === undefined)
36
+ return undefined;
37
+ if (current.driven < MIN_DRIVEN_TO_COMPARE)
38
+ return undefined;
39
+ return current.percent < best.percent ? { was: best.percent, now: current.percent } : undefined;
40
+ }
package/dist/index.d.ts CHANGED
@@ -151,7 +151,6 @@ export interface RunningServer {
151
151
  close: () => Promise<void>;
152
152
  }
153
153
  export { resolveBridgeSecurity } from './bridge/bridge-security.js';
154
- /** Start the Reticle bridge (browser WS endpoint) and, by default, the MCP stdio server. */
155
154
  export declare function start(options?: StartOptions): Promise<RunningServer>;
156
155
  /**
157
156
  * Start the Reticle bridge in daemon mode: a single HTTP server handles both the WebSocket
package/dist/index.js CHANGED
@@ -1,4 +1,9 @@
1
1
  import { join } from 'node:path';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { PROJECT_REGISTRY_FILE, emptyProjectRegistry, parseProjectRegistry } from '@reticlehq/core';
5
+ import { discoverProjectConfigs } from './cli/config-discovery.js';
6
+ import { projectCandidatesFrom, resolveArtifactRoot, } from './project/artifact-root.js';
2
7
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
8
  import { AGENT_STOPPED_NOTICE, RETICLE_DEFAULT_PORT, ReticleCommand, ReticleDir, ReticleEnv, LOOPBACK_HOST, ReplayStatus, EventType, } from '@reticlehq/core';
4
9
  import { originOf } from './session/session-manager.js';
@@ -251,6 +256,41 @@ async function resolveRealInput(options, onNavigateError, onNetworkDetail) {
251
256
  return {};
252
257
  }
253
258
  /** Start the Reticle bridge (browser WS endpoint) and, by default, the MCP stdio server. */
259
+ /**
260
+ * Resolve a session's artifact root, from everything this machine knows about where projects live.
261
+ *
262
+ * Built once per daemon and closed over by every tool call. The two sources are read lazily and
263
+ * cheaply on each call rather than snapshotted at startup: `init` can run in another terminal while
264
+ * this daemon is up, and a resolution that used a startup snapshot would keep answering with a map
265
+ * from before the project the agent is now driving existed.
266
+ */
267
+ function artifactRootResolver(daemonRoot) {
268
+ return (projectId) => {
269
+ let registry = emptyProjectRegistry();
270
+ try {
271
+ const path = join(homedir(), ReticleDir.ROOT, PROJECT_REGISTRY_FILE);
272
+ registry = existsSync(path)
273
+ ? parseProjectRegistry(JSON.parse(readFileSync(path, 'utf8')))
274
+ : registry;
275
+ }
276
+ catch {
277
+ // A cache that cannot be read is an empty cache, never an error: the daemon still resolves
278
+ // through discovery, and falls back to its own root exactly as it did before this existed.
279
+ }
280
+ let discovery = { found: [], searched: [] };
281
+ try {
282
+ discovery = discoverProjectConfigs(process.cwd());
283
+ }
284
+ catch {
285
+ // Same reasoning: a diagnostic search that throws must not take a tool call with it.
286
+ }
287
+ return resolveArtifactRoot({
288
+ projectId,
289
+ candidates: projectCandidatesFrom(discovery, registry),
290
+ daemonRoot,
291
+ });
292
+ };
293
+ }
254
294
  export async function start(options = {}) {
255
295
  const port = options.port ?? RETICLE_DEFAULT_PORT;
256
296
  // Open the user's impact record before anything can connect. Not inside the MCP branch: a daemon
@@ -302,6 +342,7 @@ export async function start(options = {}) {
302
342
  project,
303
343
  fs,
304
344
  reticleRoot,
345
+ artifactRootFor: artifactRootResolver(reticleRoot),
305
346
  now,
306
347
  bridgePort: port,
307
348
  browserProbe: probeChromium,
@@ -52,6 +52,9 @@ export function buildNodeIo(cwd) {
52
52
  homeDir() {
53
53
  return homedir();
54
54
  },
55
+ cwd() {
56
+ return cwd;
57
+ },
55
58
  rootFiles() {
56
59
  return readdirSync(cwd).filter((name) => {
57
60
  try {
@@ -3,6 +3,8 @@
3
3
  * plan (pure), optionally write the apply-steps, and print a human-readable report. All filesystem
4
4
  * access goes through `InitIo` so the orchestration is unit-testable with an in-memory IO.
5
5
  */
6
+ import { Framework } from './detect.js';
7
+ import { StepStatus } from './plan.js';
6
8
  import type { InitOutcome } from '@reticlehq/core';
7
9
  /**
8
10
  * Resolve the lockfiles set used to pick the package manager. A lockfile in the project root wins;
@@ -56,6 +58,8 @@ export interface InitIo {
56
58
  exists(relPath: string): boolean;
57
59
  /** The user's home directory (for global agent config like ~/.cursor/mcp.json). */
58
60
  homeDir(): string;
61
+ /** Absolute path of the directory `init` is running in — the project's own root. */
62
+ cwd(): string;
59
63
  /** Basenames present in the project root. */
60
64
  rootFiles(): readonly string[];
61
65
  /** Subdirectory names inside a project-relative directory; empty when it isn't one. */
@@ -99,4 +103,18 @@ export declare function findWorkspaceApps(io: Pick<InitIo, 'exists' | 'readFile'
99
103
  * the workspace redirect is kept from asking twice.
100
104
  */
101
105
  export declare const FEEDBACK_HINT: string;
106
+ /**
107
+ * The last two lines a user reads, and the order matters.
108
+ *
109
+ * This used to say only "Restart <dev server>, then ask your agent: List Reticle sessions" — and
110
+ * asking the agent was the one thing that could not work yet. `init` registers the MCP server, but
111
+ * an agent client reads its tool list when it STARTS and never re-reads it, so the session that
112
+ * just ran `init` has no `reticle_*` tools however clean the install was. The user follows the
113
+ * instruction, the agent answers "unknown tool", and the obvious conclusion is that the install
114
+ * failed.
115
+ *
116
+ * So the reload is named FIRST, before the sentence that depends on it. Omitted entirely when this
117
+ * run did not register MCP (`--no-mcp`), where it would be advice about something we did not do.
118
+ */
119
+ export declare function restartHint(framework: Framework, mcpStatus: StepStatus | undefined): string;
102
120
  export declare function runInit(options: InitOptions, io: InitIo): InitResult;