@intentic/sandbox-contract 1.252.1 → 1.254.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 (40) hide show
  1. package/dist/contracts/prepush.contract.d.ts +22 -16
  2. package/dist/contracts/prepush.contract.d.ts.map +1 -1
  3. package/dist/contracts/prepush.contract.js +12 -1
  4. package/dist/contracts/prepush.contract.js.map +1 -1
  5. package/dist/contracts/settings.contract.d.ts +38 -0
  6. package/dist/contracts/settings.contract.d.ts.map +1 -1
  7. package/dist/contracts/settings.contract.js +18 -0
  8. package/dist/contracts/settings.contract.js.map +1 -1
  9. package/dist/contracts/system.contract.d.ts +30 -30
  10. package/dist/index.d.ts +72 -31
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/protocol/peer-dial.d.ts +2 -1
  15. package/dist/protocol/peer-dial.d.ts.map +1 -1
  16. package/dist/protocol/peer-dial.js +29 -2
  17. package/dist/protocol/peer-dial.js.map +1 -1
  18. package/dist/schemas/devices.d.ts +14 -0
  19. package/dist/schemas/devices.d.ts.map +1 -1
  20. package/dist/schemas/devices.js +7 -0
  21. package/dist/schemas/devices.js.map +1 -1
  22. package/dist/schemas/repo-checks.d.ts +74 -0
  23. package/dist/schemas/repo-checks.d.ts.map +1 -0
  24. package/dist/schemas/repo-checks.js +36 -0
  25. package/dist/schemas/repo-checks.js.map +1 -0
  26. package/dist/schemas/settings.d.ts +31 -0
  27. package/dist/schemas/settings.d.ts.map +1 -1
  28. package/dist/schemas/settings.js +17 -0
  29. package/dist/schemas/settings.js.map +1 -1
  30. package/dist/state/definition.d.ts +4 -0
  31. package/dist/state/definition.d.ts.map +1 -1
  32. package/package.json +4 -4
  33. package/src/contracts/prepush.contract.ts +18 -1
  34. package/src/contracts/settings.contract.ts +22 -0
  35. package/src/index.ts +1 -0
  36. package/src/protocol/peer-dial.test.ts +73 -2
  37. package/src/protocol/peer-dial.ts +61 -5
  38. package/src/schemas/devices.ts +24 -3
  39. package/src/schemas/repo-checks.ts +64 -0
  40. package/src/schemas/settings.ts +28 -1
@@ -1,4 +1,5 @@
1
1
  import { oc } from "@orpc/contract";
2
+ import { RepoChecksAdoptSchema, RepoChecksListSchema } from "../schemas/repo-checks.js";
2
3
  import { BuiltinPromptSchema, BuiltinPromptTextSchema, RuleFiringsSchema, SandboxSettingsSchema, SavingsReportSchema } from "../schemas/settings.js";
3
4
  import { OkSchema } from "../schemas/shared.js";
4
5
  import { DayWindowQuerySchema } from "../schemas/usage.js";
@@ -54,4 +55,25 @@ export const settingsContract = {
54
55
  "A separate read rather than a field on the settings, because a rule firing is not somebody editing anything: folding it in would turn every firing into a settings write and put a self-changing value inside the object a screen edits.",
55
56
  })
56
57
  .output(RuleFiringsSchema),
58
+ // Read off the repositories themselves, not out of the settings file: the declaration is a tracked file in each
59
+ // repository, and only the owner's answer to it lives here.
60
+ repoChecks: oc
61
+ .route({
62
+ method: "GET",
63
+ path: "/settings/repo-checks",
64
+ summary: "What each repository asks to run on its own code",
65
+ description:
66
+ "Every repository that declares its own checks at `.intentic/checks.json`, what it declares, and whether you have switched it on. A repository declares what to run because the command belongs beside the scripts it names; nothing it declares runs until you say so.",
67
+ })
68
+ .output(RepoChecksListSchema),
69
+ adoptRepoChecks: oc
70
+ .route({
71
+ method: "POST",
72
+ path: "/settings/repo-checks/adopt",
73
+ summary: "Switch a repository's own checks on or off",
74
+ description:
75
+ "Adopts exactly what that repository declares as it stands now. If the declaration changes afterwards it stops running until you adopt it again, so a command nobody has read cannot inherit the answer given to a different one.",
76
+ })
77
+ .input(RepoChecksAdoptSchema)
78
+ .output(OkSchema),
57
79
  };
package/src/index.ts CHANGED
@@ -168,6 +168,7 @@ export * from "./schemas/provider-oauth.js";
168
168
  export * from "./schemas/provider-subscriptions.js";
169
169
  export * from "./schemas/public.js";
170
170
  export * from "./schemas/push.js";
171
+ export * from "./schemas/repo-checks.js";
171
172
  export * from "./schemas/secrets.js";
172
173
  export * from "./schemas/sessions.js";
173
174
  export * from "./schemas/settings.js";
@@ -35,6 +35,10 @@ class FakeSocket implements SocketLike {
35
35
  says(): void {
36
36
  this.emit("message");
37
37
  }
38
+ // What a socket that cannot reach its far end emits before it closes: no code, no reason, just a fault.
39
+ errors(): void {
40
+ this.emit("error");
41
+ }
38
42
  private emit(type: string, code?: number): void {
39
43
  for (const listener of this.listeners.get(type) ?? []) {
40
44
  listener(code === undefined ? {} : { code });
@@ -57,7 +61,7 @@ const ladder = (delay = 1_000) => {
57
61
  };
58
62
  };
59
63
 
60
- const dialling = () => {
64
+ const dialling = (delay?: number) => {
61
65
  const sockets: FakeSocket[] = [];
62
66
  const attached: FakeSocket[] = [];
63
67
  const said: string[] = [];
@@ -70,7 +74,7 @@ const dialling = () => {
70
74
  },
71
75
  hello: () => ({ type: "hello", token: "iht_test", version: "1.0.0" }),
72
76
  attach: (socket) => void attached.push(socket),
73
- backoff: ladder(),
77
+ backoff: ladder(delay),
74
78
  silenceMs: SILENCE_MS,
75
79
  log: (message) => void said.push(message),
76
80
  revoked,
@@ -153,6 +157,73 @@ test("a socket that goes silent is abandoned and redialled, though no close ever
153
157
  }
154
158
  });
155
159
 
160
+ /* THE FAR END THAT IS NEVER COMING BACK, which is not a failure the loop can fix and not one it may narrate
161
+ * forever: a sandbox deleted, a tunnel repointed, a machine left on for a week. Every attempt here errors and
162
+ * closes without ever opening, exactly as a laptop's agent did 1,992 times into a 2.3 MB log. What is asserted
163
+ * is BOTH halves: the log stops repeating, and the dialling does not slow down to achieve it. */
164
+ test("a far end that never answers is reported a few times, then retried quietly at the same cadence", async () => {
165
+ vi.useFakeTimers();
166
+ try {
167
+ const { link, sockets, said } = dialling(60_000);
168
+ for (let attempt = 1; attempt <= 20; attempt += 1) {
169
+ // oxlint-disable-next-line eslint/no-await-in-loop -- one attempt after another is the thing under test
170
+ await vi.waitFor(() => expect(sockets).toHaveLength(attempt));
171
+ sockets.at(-1)?.errors();
172
+ sockets.at(-1)?.drops(1006);
173
+ // oxlint-disable-next-line eslint/no-await-in-loop -- the ladder's own wait, serial by construction
174
+ await vi.advanceTimersByTimeAsync(60_000);
175
+ }
176
+
177
+ // Twenty minutes of a dead link: eight lines, the last of them a count rather than a repetition.
178
+ expect(said).toEqual([
179
+ "connection error",
180
+ "disconnected (1006); reconnecting in 60s",
181
+ "connection error",
182
+ "disconnected (1006); reconnecting in 60s",
183
+ "connection error",
184
+ "disconnected (1006); reconnecting in 60s",
185
+ expect.stringContaining("still nothing after 4 attempts"),
186
+ expect.stringContaining("14 failed attempts"),
187
+ ]);
188
+ // The retries themselves are untouched: one dial per ladder delay, still going, plus the one now armed.
189
+ await vi.waitFor(() => expect(sockets).toHaveLength(21));
190
+
191
+ link.stop();
192
+ await link.done;
193
+ } finally {
194
+ vi.useRealTimers();
195
+ }
196
+ });
197
+
198
+ // Quiet is a property of the current outage, not of the link: whatever it hid, the next one starts from nothing.
199
+ test("a link that comes back is loud again about the outage after it", async () => {
200
+ vi.useFakeTimers();
201
+ try {
202
+ const { link, sockets, said } = dialling();
203
+ for (let attempt = 1; attempt <= 5; attempt += 1) {
204
+ // oxlint-disable-next-line eslint/no-await-in-loop -- one attempt after another is the thing under test
205
+ await vi.waitFor(() => expect(sockets).toHaveLength(attempt));
206
+ sockets.at(-1)?.drops(1006);
207
+ // oxlint-disable-next-line eslint/no-await-in-loop -- the ladder's own wait, serial by construction
208
+ await vi.advanceTimersByTimeAsync(1_000);
209
+ }
210
+ const whileQuiet = said.length;
211
+
212
+ await vi.waitFor(() => expect(sockets).toHaveLength(6));
213
+ sockets.at(-1)?.opens();
214
+ await vi.waitFor(() => expect(said.at(-1)).toBe("connected #6"));
215
+ sockets.at(-1)?.drops(1006);
216
+
217
+ expect(said.at(-1)).toBe("disconnected (1006); reconnecting in 1s");
218
+ expect(said).toHaveLength(whileQuiet + 2);
219
+
220
+ link.stop();
221
+ await link.done;
222
+ } finally {
223
+ vi.useRealTimers();
224
+ }
225
+ });
226
+
156
227
  test("a refused enrollment (1008) is never retried and is reported once", async () => {
157
228
  vi.useFakeTimers();
158
229
  try {
@@ -5,6 +5,20 @@
5
5
  // Reconnect backoff: fast floor for a restart, low cap so a reopened laptop is back within a minute.
6
6
  export const PEER_LINK_BACKOFF = { floorMs: 1_000, capMs: 30_000, stableMs: 60_000 } as const;
7
7
 
8
+ /* HOW MUCH A LINK THAT CANNOT BE REACHED IS ALLOWED TO SAY, which is a different question from how often it
9
+ * may try. At the cap above, a far end that is gone for good — a sandbox deleted, a tunnel pointed elsewhere —
10
+ * costs two lines every 30 seconds for as long as the machine is on: 5,760 a day. One laptop's agent had
11
+ * written 1,992 pairs of them, 2.3 MB, and this log is exactly where its owner had been sent to read why a
12
+ * DIFFERENT thing had failed; the answer was in there, under an hour of repetition.
13
+ *
14
+ * The cadence is not the problem and is deliberately untouched — a reopened laptop must be back within a
15
+ * minute, which is what the low cap buys. The REPETITION is. So the first few failures are reported in full,
16
+ * then the loop says so once more to mark that it is going quiet, and after that repeats itself at most once
17
+ * per QUIET_LOG_MS with the attempt count that says how long it has been trying. A link that opens resets all
18
+ * of it: every reconnect is news, and the reconnect line is what reports it. */
19
+ const LOUD_ATTEMPTS = 3;
20
+ const QUIET_LOG_MS = 10 * 60_000;
21
+
8
22
  /* HOW LONG A SOCKET MAY SAY NOTHING before this side calls the link dead, as a multiple of the door's own
9
23
  * heartbeat: the hub pings every live peer on an interval (peer-hub.ts), so a socket with nothing on it for
10
24
  * three heartbeats is not quiet, it is gone.
@@ -57,12 +71,16 @@ export interface PeerDialSpec<S extends SocketLike> {
57
71
  readonly revoked: () => void;
58
72
  }
59
73
 
74
+ // What the socket is doing right now. "connecting" covers a dial in flight and a retry waiting on the ladder:
75
+ // nobody should start another. Named, because processes that are not this one report it (a machine agent stamps
76
+ // it for `status`, which otherwise has only the link list on disk and no idea whether any of it is up).
77
+ export type PeerLinkState = "open" | "connecting" | "closed";
78
+
60
79
  export interface PeerLink {
61
80
  // Resolves when the loop is asked to stop or refused for good; never rejects, a connection error is a retry.
62
81
  readonly done: Promise<void>;
63
82
  readonly stop: (reason?: string) => void;
64
- // "connecting" covers a dial in flight and a retry waiting on the ladder: nobody should start another.
65
- readonly state: () => "open" | "connecting" | "closed";
83
+ readonly state: () => PeerLinkState;
66
84
  }
67
85
 
68
86
  export const dialPeer = <S extends SocketLike>(spec: PeerDialSpec<S>): PeerLink => {
@@ -77,6 +95,32 @@ export const dialPeer = <S extends SocketLike>(spec: PeerDialSpec<S>): PeerLink
77
95
  const done = new Promise<void>((resolve) => {
78
96
  resolveDone = resolve;
79
97
  });
98
+ // Consecutive attempts that have failed since this link was last open, and when the loop last complained out
99
+ // loud: between them they are the whole of the quiet rule above.
100
+ let failures = 0;
101
+ let quietSince = 0;
102
+
103
+ /* What ONE failed attempt is allowed to say. Three sentences rather than one repeated forever: the first few
104
+ * failures in full, then the line that marks the loop going quiet (so a reader who sees it knows the retries
105
+ * continue unlogged), then a complaint carrying the attempt count at most once per window. */
106
+ const complain = (said: string, delay: number): void => {
107
+ const every = `retrying every ${Math.round(delay / 1000)}s`;
108
+ if (failures <= LOUD_ATTEMPTS) {
109
+ spec.log(`${said}; reconnecting in ${Math.round(delay / 1000)}s`);
110
+ return;
111
+ }
112
+ if (failures === LOUD_ATTEMPTS + 1) {
113
+ quietSince = Date.now();
114
+ spec.log(
115
+ `${said}; still nothing after ${failures} attempts — ${every}, and saying so at most every ${Math.round(QUIET_LOG_MS / 60_000)} minutes from here`,
116
+ );
117
+ return;
118
+ }
119
+ if (Date.now() - quietSince >= QUIET_LOG_MS) {
120
+ quietSince = Date.now();
121
+ spec.log(`${said}; ${failures} failed attempts, ${every}`);
122
+ }
123
+ };
80
124
 
81
125
  const open = async (): Promise<void> => {
82
126
  waiting = true;
@@ -123,7 +167,8 @@ export const dialPeer = <S extends SocketLike>(spec: PeerDialSpec<S>): PeerLink
123
167
  }
124
168
  const delay = spec.backoff.next(openedAt === undefined ? 0 : Date.now() - openedAt);
125
169
  openedAt = undefined;
126
- spec.log(`${said}; reconnecting in ${Math.round(delay / 1000)}s`);
170
+ failures += 1;
171
+ complain(said, delay);
127
172
  waiting = true;
128
173
  setTimeout(() => void open(), delay);
129
174
  };
@@ -149,6 +194,10 @@ export const dialPeer = <S extends SocketLike>(spec: PeerDialSpec<S>): PeerLink
149
194
  return; // abandoned mid-connect: this socket is already closed and its replacement is on the ladder
150
195
  }
151
196
  openedAt = Date.now();
197
+ // A link that is up owes nothing to the failures behind it: the next outage is news again, and the
198
+ // "connected to …" line this open is about to log is what reports the recovery.
199
+ failures = 0;
200
+ quietSince = 0;
152
201
  arm();
153
202
  spec.attach(ws);
154
203
  const send = (hello: Record<string, unknown>): void => {
@@ -184,8 +233,15 @@ export const dialPeer = <S extends SocketLike>(spec: PeerDialSpec<S>): PeerLink
184
233
  drop(`disconnected (${event.code ?? "no code"})`);
185
234
  });
186
235
 
187
- // Always followed by a close event that owns the retry; this only records a cause the close code can't carry.
188
- ws.addEventListener("error", () => spec.log("connection error"));
236
+ /* Always followed by a close event that owns the retry; this only records a cause the close code can't
237
+ * carry, so it is silenced with the rest once the loop goes quiet — it is half of every repeated pair in
238
+ * a dead link's log, and it says nothing the drop line beside it does not. The attempt that finally
239
+ * reconnects is loud again, this line included. */
240
+ ws.addEventListener("error", () => {
241
+ if (failures < LOUD_ATTEMPTS) {
242
+ spec.log("connection error");
243
+ }
244
+ });
189
245
  };
190
246
 
191
247
  void open();
@@ -237,9 +237,14 @@ export const AGENT_STALL_AFTER_MS = 60_000;
237
237
  export const agentStalled = (agent: DeviceAgent, now: number): boolean =>
238
238
  agent.running && agent.lastTickAt !== undefined && now - agent.lastTickAt > AGENT_STALL_AFTER_MS;
239
239
  export const DeviceReportSchema = z.object({
240
- // OS hostname; the join key that dedupes a machine seen via sync and via its `host` capability.
240
+ // OS hostname; the join key that dedupes a machine seen via sync and via its `host` capability. Not unique on its
241
+ // own: a WSL distro inherits the Windows machine's name, so `wsl` below is what tells those apart.
241
242
  hostname: z.string(),
242
243
  os: z.string(),
244
+ // Present only inside a WSL distro. `distro` is that distro's own name ("Arch", "Ubuntu-22.04"), empty when the
245
+ // machine won't say. Windows, and every distro it hosts, all answer `hostname` with the same string while being
246
+ // separate filesystems running separate agents, so this is the only thing that keeps them apart.
247
+ wsl: z.object({ distro: z.string() }).optional(),
243
248
  // Filled by the reader, never the agent; empty means no Docker or nothing looked, not that none exist.
244
249
  sandboxes: z.array(DeviceSandboxSchema),
245
250
  pairings: z.array(DevicePairingSchema),
@@ -256,6 +261,21 @@ export type DeviceReport = z.infer<typeof DeviceReportSchema>;
256
261
  export const REPORT_QUIET_AFTER_MS = 60_000;
257
262
  export const reportQuiet = (report: DeviceReport, receivedAt: number): boolean => receivedAt - report.capturedAt > REPORT_QUIET_AFTER_MS;
258
263
 
264
+ // The environment a reading came from, as opposed to the machine hosting it: a Windows install and every WSL distro
265
+ // on it are separate filesystems running separate agents, and all of them answer `hostname` with the same string.
266
+ // Undefined when nothing has reported — an absence of evidence, never read as agreement.
267
+ export const environmentOf = (report: DeviceReport | undefined): string | undefined =>
268
+ report === undefined ? undefined : report.wsl === undefined ? report.os : `wsl:${report.wsl.distro}`;
269
+
270
+ // Whether two readings positively disagree about which environment they describe. False whenever either side has not
271
+ // said, so this only ever blocks a fold it holds evidence against, and an agent too old to report `wsl` keeps the
272
+ // behaviour it had before the field existed.
273
+ export const differentEnvironment = (left: DeviceReport | undefined, right: DeviceReport | undefined): boolean => {
274
+ const a = environmentOf(left);
275
+ const b = environmentOf(right);
276
+ return a !== undefined && b !== undefined && a !== b;
277
+ };
278
+
259
279
  // Compares running build against installed; silent when the loop is stopped, nothing installed, or installed is a dev
260
280
  // build. An unstamped `running` still counts as skew.
261
281
  export const agentBuildSkew = (agent: DeviceAgent): { readonly running: string | undefined; readonly installed: string } | undefined => {
@@ -278,8 +298,9 @@ export const DeviceGapSchema = z.enum([
278
298
  "unreported",
279
299
  ]);
280
300
  export type DeviceGap = z.infer<typeof DeviceGapSchema>;
281
- // A machine may be reachable via desktop sync and a host capability at once; the two are reconciled on `hostname`, and
282
- // left as separate rows when there is nothing to reconcile them by.
301
+ // A machine may be reachable via desktop sync and a host capability at once; the two are reconciled on `hostname` plus
302
+ // the environment it came from (`environmentOf`), and left as separate rows when there is nothing to reconcile them by
303
+ // or when the environments positively disagree.
283
304
  // `machine` is the enrollment's name for the box (the ssh key's comment): what reports are filed under and what the
284
305
  // revoke route takes. Two machines sharing a key comment share one enrollment identity.
285
306
  export const DeviceSyncSchema = z.object({
@@ -0,0 +1,64 @@
1
+ // repo-checks: what a REPOSITORY says should be run on its own code, declared in the repository, at
2
+ // `<repo>/.intentic/checks.json`.
3
+ //
4
+ // The line between this file and the sandbox's own settings is authority, not subject matter: a repository may declare
5
+ // WHAT to run, because the command belongs beside the scripts it names and travels with the checkout; only the sandbox
6
+ // owner decides what happens when it fails (whether work lands, whether a push goes), and that stays in settings.json.
7
+ // Nothing declared here runs until the owner adopts it for that repository (settings `adoptedChecks`), the same rule
8
+ // git keeps for hooks, which are never cloned.
9
+ import { z } from "zod";
10
+
11
+ // Named for the occasion as a repository would say it, not for the daemon's wire moment: `turn` is `turn.ending` and
12
+ // `push` is `push.starting` (rules/repo-checks.ts maps them). Two, because these are the two occasions whose command a
13
+ // repository actually owns; a verdict moment has nothing here to express.
14
+ export const RepoCheckMomentSchema = z.enum(["turn", "push"]);
15
+ export type RepoCheckMoment = z.infer<typeof RepoCheckMomentSchema>;
16
+
17
+ export const RepoCheckSchema = z.object({
18
+ when: RepoCheckMomentSchema.describe("When to run it: `turn` before the assistant finishes, `push` before code leaves the machine."),
19
+ run: z.string().min(1).max(500).describe("The command, run in this repository's own directory, so it reads as it would in a terminal there."),
20
+ label: z.string().min(1).max(80).optional().describe("What to call it on screen. Absent names it after the command."),
21
+ // Same ceiling as a rule's own command; past it the process group is killed and the run is a failure, never a
22
+ // silent pass.
23
+ timeoutMs: z.number().min(60_000).max(3_600_000).optional().describe("How long it may take before it is killed and counted as failed."),
24
+ // Repo-relative, as anybody reading this file would write them; the daemon prefixes the repo id before matching,
25
+ // since a rule's globs are workspace-relative.
26
+ paths: z
27
+ .array(z.string().min(1))
28
+ .max(20)
29
+ .optional()
30
+ .describe("Only run it when the change touches these paths, written relative to this repository. Absent runs it on every change here."),
31
+ });
32
+ export type RepoCheck = z.infer<typeof RepoCheckSchema>;
33
+
34
+ // The file itself. One key, so a second concern can be added later without breaking a file anyone has written.
35
+ export const RepoChecksFileSchema = z.object({ checks: z.array(RepoCheckSchema).max(10).default([]) });
36
+ export type RepoChecksFile = z.infer<typeof RepoChecksFileSchema>;
37
+
38
+ // One repository, as a screen reads it: what it declares, and where that stands with the owner.
39
+ export const RepoChecksSummarySchema = z.object({
40
+ repo: z.string().describe('Which repository, by its workspace id ("root" is the workspace itself).'),
41
+ path: z.string().describe("Where the declaration lives, relative to the workspace, whether or not the file exists yet."),
42
+ checks: z.array(RepoCheckSchema).describe("What it declares, in the order the file lists them."),
43
+ adopted: z
44
+ .boolean()
45
+ .describe("Whether these are running. False means declared and inert: nothing a repository writes runs until the owner switches it on."),
46
+ changed: z
47
+ .boolean()
48
+ .describe(
49
+ "Whether the declaration changed since it was adopted, which holds it until the owner looks again. True only for a repository that was adopted before.",
50
+ ),
51
+ error: z.string().optional().describe("Why the file could not be read, when it exists but does not parse. The checks list is empty in that case."),
52
+ });
53
+ export type RepoChecksSummary = z.infer<typeof RepoChecksSummarySchema>;
54
+ export const RepoChecksListSchema = z.object({
55
+ repos: z.array(RepoChecksSummarySchema).describe("Every repository that declares checks, plus any the owner has adopted before, sorted by id."),
56
+ });
57
+ export type RepoChecksList = z.infer<typeof RepoChecksListSchema>;
58
+ export const RepoChecksAdoptSchema = z.object({
59
+ repo: z.string().min(1).describe("Which repository's declaration to switch."),
60
+ on: z
61
+ .boolean()
62
+ .describe("On adopts what it declares as it stands now; off stops running it. Adopting again is how a changed declaration is accepted."),
63
+ });
64
+ export type RepoChecksAdopt = z.infer<typeof RepoChecksAdoptSchema>;
@@ -369,8 +369,18 @@ export const SandboxSettingsSchema = z.object({
369
369
  .describe(
370
370
  "Whether a turn killed by the sandbox restarting is re-run once it comes back. Off to begin with, for the same reason: it would spend your allowance on work you are not watching and edit files while you are still waiting for the sandbox to return. Either way the interruption is recorded rather than silently lost.",
371
371
  ),
372
+ // Which repositories' own declarations (`<repo>/.intentic/checks.json`) the owner has switched on, each against the
373
+ // fingerprint of what was declared when they did. A declaration that has since changed no longer matches its
374
+ // fingerprint and is held rather than run, which is what makes adoption a decision about a command rather than a
375
+ // permanent permission on a folder. Keyed by repo id, so `/` in the key is ordinary.
376
+ adoptedChecks: z
377
+ .record(z.string(), z.string())
378
+ .default({})
379
+ .describe(
380
+ "Which repositories may run the checks they declare for themselves, and exactly which version of those checks you agreed to. A repository's declaration does nothing until it appears here, the same rule git keeps for hooks, which are never cloned; and a declaration that changes afterwards is held until you look at it again.",
381
+ ),
372
382
  // Lives in the owner's own settings, not the workspace: a rule can hold work and gate a push, so it answers to the
373
- // sandbox owner alone. Repo- or extension-contributed rules aren't supported yet.
383
+ // sandbox owner alone. A repository may declare a COMMAND of its own (see `adoptedChecks`), never a verdict.
374
384
  rules: z
375
385
  .array(RuleSchema)
376
386
  .max(50)
@@ -511,6 +521,22 @@ export const TierReportSchema = z.object({
511
521
  denied: z.number(),
512
522
  });
513
523
  export type TierReport = z.infer<typeof TierReportSchema>;
524
+ // One dependency version or library improvement suggested or pinned.
525
+ export const DependencyImprovementSchema = z.object({
526
+ prevented: z.string(),
527
+ chosen: z.string(),
528
+ reason: z.string(),
529
+ at: z.number().optional(),
530
+ });
531
+ export type DependencyImprovement = z.infer<typeof DependencyImprovementSchema>;
532
+ // Rollup of registry freshness interventions over the queried day window.
533
+ export const DependencySavingsSchema = z.object({
534
+ checked: z.number(),
535
+ improved: z.number(),
536
+ recent: z.array(DependencyImprovementSchema),
537
+ updatedAt: z.number().optional(),
538
+ });
539
+ export type DependencySavings = z.infer<typeof DependencySavingsSchema>;
514
540
  export const SavingsReportSchema = z.object({
515
541
  input: InputSavingsSchema,
516
542
  search: TurnExperimentSchema.optional(),
@@ -518,5 +544,6 @@ export const SavingsReportSchema = z.object({
518
544
  map: TurnExperimentSchema.optional(),
519
545
  // Automatic tier selection's readout, see TierReportSchema. Absent ⇒ nothing was judged in the window.
520
546
  tier: TierReportSchema.optional(),
547
+ dependencies: DependencySavingsSchema.optional(),
521
548
  });
522
549
  export type SavingsReport = z.infer<typeof SavingsReportSchema>;