@intentic/sandbox-contract 1.302.0 → 1.304.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 (139) hide show
  1. package/dist/contracts/agent.contract.d.ts +6 -9
  2. package/dist/contracts/agent.contract.d.ts.map +1 -1
  3. package/dist/contracts/agents.contract.d.ts +207 -433
  4. package/dist/contracts/agents.contract.d.ts.map +1 -1
  5. package/dist/contracts/agents.contract.js +6 -24
  6. package/dist/contracts/agents.contract.js.map +1 -1
  7. package/dist/contracts/areas.contract.d.ts +24 -0
  8. package/dist/contracts/areas.contract.d.ts.map +1 -0
  9. package/dist/contracts/areas.contract.js +32 -0
  10. package/dist/contracts/areas.contract.js.map +1 -0
  11. package/dist/contracts/automations.contract.d.ts +3 -0
  12. package/dist/contracts/automations.contract.d.ts.map +1 -1
  13. package/dist/contracts/capabilities.contract.d.ts +0 -46
  14. package/dist/contracts/capabilities.contract.d.ts.map +1 -1
  15. package/dist/contracts/endpoints.contract.d.ts +131 -0
  16. package/dist/contracts/endpoints.contract.d.ts.map +1 -1
  17. package/dist/contracts/endpoints.contract.js +50 -0
  18. package/dist/contracts/endpoints.contract.js.map +1 -1
  19. package/dist/contracts/providers.contract.d.ts +20 -0
  20. package/dist/contracts/providers.contract.d.ts.map +1 -1
  21. package/dist/contracts/providers.contract.js +1 -0
  22. package/dist/contracts/providers.contract.js.map +1 -1
  23. package/dist/contracts/runner.contract.d.ts +98 -95
  24. package/dist/contracts/runner.contract.d.ts.map +1 -1
  25. package/dist/contracts/sessions.contract.d.ts +0 -1
  26. package/dist/contracts/sessions.contract.d.ts.map +1 -1
  27. package/dist/contracts/settings.contract.d.ts +108 -6
  28. package/dist/contracts/settings.contract.d.ts.map +1 -1
  29. package/dist/contracts/settings.contract.js +19 -1
  30. package/dist/contracts/settings.contract.js.map +1 -1
  31. package/dist/contracts/system.contract.d.ts +25 -12
  32. package/dist/contracts/system.contract.d.ts.map +1 -1
  33. package/dist/events/agent-events.d.ts +1 -3
  34. package/dist/events/agent-events.d.ts.map +1 -1
  35. package/dist/events/agent-events.js +1 -0
  36. package/dist/events/agent-events.js.map +1 -1
  37. package/dist/events/system-events.d.ts +31 -6
  38. package/dist/events/system-events.d.ts.map +1 -1
  39. package/dist/events/transcript.d.ts +2 -6
  40. package/dist/events/transcript.d.ts.map +1 -1
  41. package/dist/events/transcript.js +5 -1
  42. package/dist/events/transcript.js.map +1 -1
  43. package/dist/index.d.ts +489 -544
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +9 -1
  46. package/dist/index.js.map +1 -1
  47. package/dist/models/agent-catalog.d.ts.map +1 -1
  48. package/dist/models/agent-catalog.js +1 -1
  49. package/dist/models/agent-catalog.js.map +1 -1
  50. package/dist/policy/fence-paths.d.ts +10 -0
  51. package/dist/policy/fence-paths.d.ts.map +1 -0
  52. package/dist/policy/fence-paths.js +58 -0
  53. package/dist/policy/fence-paths.js.map +1 -0
  54. package/dist/policy/persona-home.d.ts +10 -0
  55. package/dist/policy/persona-home.d.ts.map +1 -0
  56. package/dist/policy/persona-home.js +11 -0
  57. package/dist/policy/persona-home.js.map +1 -0
  58. package/dist/schemas/agents.d.ts +54 -19
  59. package/dist/schemas/agents.d.ts.map +1 -1
  60. package/dist/schemas/agents.js +11 -22
  61. package/dist/schemas/agents.js.map +1 -1
  62. package/dist/schemas/areas.d.ts +21 -0
  63. package/dist/schemas/areas.d.ts.map +1 -0
  64. package/dist/schemas/areas.js +32 -0
  65. package/dist/schemas/areas.js.map +1 -0
  66. package/dist/schemas/automations.d.ts +20 -3
  67. package/dist/schemas/automations.d.ts.map +1 -1
  68. package/dist/schemas/automations.js +2 -0
  69. package/dist/schemas/automations.js.map +1 -1
  70. package/dist/schemas/capabilities.d.ts +11 -46
  71. package/dist/schemas/capabilities.d.ts.map +1 -1
  72. package/dist/schemas/capabilities.js +15 -14
  73. package/dist/schemas/capabilities.js.map +1 -1
  74. package/dist/schemas/personas.d.ts.map +1 -1
  75. package/dist/schemas/personas.js +5 -1
  76. package/dist/schemas/personas.js.map +1 -1
  77. package/dist/schemas/providers/usage.d.ts +4 -0
  78. package/dist/schemas/providers/usage.d.ts.map +1 -1
  79. package/dist/schemas/providers/usage.js +4 -0
  80. package/dist/schemas/providers/usage.js.map +1 -1
  81. package/dist/schemas/settings.d.ts +98 -3
  82. package/dist/schemas/settings.d.ts.map +1 -1
  83. package/dist/schemas/settings.js +46 -14
  84. package/dist/schemas/settings.js.map +1 -1
  85. package/dist/schemas/shared.d.ts +2 -0
  86. package/dist/schemas/shared.d.ts.map +1 -1
  87. package/dist/schemas/shared.js +3 -3
  88. package/dist/schemas/shared.js.map +1 -1
  89. package/dist/schemas/turn-break.d.ts +32 -0
  90. package/dist/schemas/turn-break.d.ts.map +1 -0
  91. package/dist/schemas/turn-break.js +16 -0
  92. package/dist/schemas/turn-break.js.map +1 -0
  93. package/dist/state/definition.d.ts +36 -56
  94. package/dist/state/definition.d.ts.map +1 -1
  95. package/dist/state/history-state.d.ts.map +1 -1
  96. package/dist/state/history-state.js +1 -0
  97. package/dist/state/history-state.js.map +1 -1
  98. package/dist/state/workspace-state.d.ts +5 -0
  99. package/dist/state/workspace-state.d.ts.map +1 -1
  100. package/dist/state/workspace-state.js +1 -0
  101. package/dist/state/workspace-state.js.map +1 -1
  102. package/dist/text/transcript-fold.d.ts.map +1 -1
  103. package/dist/text/transcript-fold.js +3 -7
  104. package/dist/text/transcript-fold.js.map +1 -1
  105. package/dist/time/zone.d.ts +27 -0
  106. package/dist/time/zone.d.ts.map +1 -0
  107. package/dist/time/zone.js +35 -0
  108. package/dist/time/zone.js.map +1 -0
  109. package/package.json +16 -5
  110. package/src/contracts/agents.contract.ts +7 -30
  111. package/src/contracts/areas.contract.ts +42 -0
  112. package/src/contracts/endpoints.contract.ts +82 -0
  113. package/src/contracts/providers.contract.ts +5 -0
  114. package/src/contracts/settings.contract.ts +28 -0
  115. package/src/events/agent-events.ts +4 -1
  116. package/src/events/transcript.ts +9 -1
  117. package/src/index.ts +20 -1
  118. package/src/models/agent-catalog.ts +1 -1
  119. package/src/policy/fence-paths.test.ts +114 -0
  120. package/src/policy/fence-paths.ts +99 -0
  121. package/src/policy/persona-home.test.ts +52 -0
  122. package/src/policy/persona-home.ts +39 -0
  123. package/src/schemas/agents.ts +22 -35
  124. package/src/schemas/areas.test.ts +40 -0
  125. package/src/schemas/areas.ts +49 -0
  126. package/src/schemas/automations.ts +8 -0
  127. package/src/schemas/capabilities.ts +37 -19
  128. package/src/schemas/personas.ts +15 -4
  129. package/src/schemas/providers/usage.ts +9 -0
  130. package/src/schemas/settings.ts +98 -26
  131. package/src/schemas/shared.ts +6 -5
  132. package/src/schemas/turn-break.ts +49 -0
  133. package/src/state/history-state.ts +4 -0
  134. package/src/state/workspace-state.test.ts +7 -3
  135. package/src/state/workspace-state.ts +9 -2
  136. package/src/text/transcript-fold.test.ts +3 -5
  137. package/src/text/transcript-fold.ts +3 -7
  138. package/src/time/zone.test.ts +90 -0
  139. package/src/time/zone.ts +118 -0
@@ -0,0 +1,39 @@
1
+ import { type Fence, fenceCovers } from "./fence-paths.js";
2
+
3
+ // Where a persona card LIVES, and therefore who may act through it. A card is not granted per person any more: a
4
+ // person holds areas, an area is folders, and a card whose home those folders cover is one that person may wear.
5
+ // Pure and shared, because the daemon refuses on this answer and the Access tab draws the consequence from it, and
6
+ // two implementations would disagree at exactly the boundary that matters.
7
+
8
+ // The two fields a card names a place with; structural rather than the Persona type, so this stays free of the
9
+ // schema layer that imports it.
10
+ export interface PersonaPlace {
11
+ readonly workspace?:
12
+ | {
13
+ readonly startIn?: string | undefined;
14
+ readonly folders?: readonly string[] | undefined;
15
+ }
16
+ | undefined;
17
+ }
18
+
19
+ /**
20
+ * The card's home as a fence: the folder it opens in, else the folders it may touch, else undefined — the workspace
21
+ * root, which only somebody holding the whole workspace covers.
22
+ * `startIn` wins over `folders` because where a conversation OPENS is the one place the card certainly works; the
23
+ * folders it may touch are a ceiling, usually the whole tree, and reading that as the home would make nearly every
24
+ * card owner-only.
25
+ */
26
+ export const personaHome = (card: PersonaPlace): Fence => {
27
+ const startIn = card.workspace?.startIn;
28
+ if (startIn !== undefined && startIn !== "") {
29
+ return [startIn];
30
+ }
31
+ const folders = card.workspace?.folders;
32
+ return folders === undefined || folders.length === 0 ? undefined : folders;
33
+ };
34
+
35
+ /**
36
+ * Whether a person fenced to these folders may act through this card. An unfenced holder reaches every card; a fenced
37
+ * one reaches those homed inside its folders, and never a card that lives at the workspace root.
38
+ */
39
+ export const fenceHoldsPersona = (fence: Fence, card: PersonaPlace): boolean => fenceCovers(fence, personaHome(card));
@@ -2,6 +2,7 @@
2
2
  import { z } from "zod";
3
3
  import { AgentHarnessSchema, AgentOriginSchema, AgentProviderSchema, ForkedFromSchema } from "./agent.js";
4
4
  import { LoopStateSchema } from "./loops.js";
5
+ import { LimitPolicySchema, RetryPolicySchema, TurnBreakPolicySchema, TurnBreakSchema } from "./turn-break.js";
5
6
  import { EMOJI_MAX_LENGTH, isSingleEmoji } from "../text/emoji.js";
6
7
  // A fleet agent is any conversation with a registry entry, keyed by conversationId. Isolated ones own a git worktree
7
8
  // (branch agent/<id>); workspace conversations have none, but both share one status/activity/cost lifecycle.
@@ -252,15 +253,11 @@ export const AgentSummarySchema = z.object({
252
253
  .describe(
253
254
  "This conversation's own answer to whether its work merges automatically. Absent means it follows the sandbox-wide setting, which is the common case.",
254
255
  ),
255
- // Per-conversation override, written by the in-chat retry press (not the settings toggle) so one late-night click
256
- // can't arm every agent; absent inherits the sandbox setting.
257
- resumeAfterOutage: z.boolean().optional(),
258
- // Off by default, unlike its neighbors: firing the moment a spent allowance reopens spends a window the user may be
259
- // saving.
260
- resumeAfterLimit: z.boolean().optional(),
261
- // Per-conversation override for moving a held turn to another account with room the moment it's refused; absent
262
- // inherits.
263
- moveAfterLimit: z.boolean().optional(),
256
+ // This conversation's own answer to each ending's one question, written by the in-chat control (not the settings
257
+ // row) so one late-night click can't arm every agent; absent inherits the sandbox-wide policy.
258
+ limitPolicy: LimitPolicySchema.optional(),
259
+ outagePolicy: RetryPolicySchema.optional(),
260
+ stopPolicy: RetryPolicySchema.optional(),
264
261
  // A collaborator's ask to land (collaborators can't merge themselves); cleared by whichever merge or discard
265
262
  // answers it.
266
263
  landRequested: z
@@ -294,6 +291,14 @@ export const AgentSummarySchema = z.object({
294
291
  .describe(
295
292
  "Who asked for the first turn, as the sandbox verified it: a member's email, token:<label> for a program's control token, or agent:<conversation id> for a child another conversation spawned. Absent when nothing was verified (a wake, a loopback caller).",
296
293
  ),
294
+ // The fence it was born with. Rides the summary because it is what decides who may see the row at all
295
+ // (auth/fleet-scope.ts visibleTo); without it here a fenced reader's board would be empty of even their own work.
296
+ areas: z
297
+ .array(z.string())
298
+ .optional()
299
+ .describe(
300
+ "Which named areas of the workspace this conversation was started within, latched from whoever asked for the first turn. Absent means its starter held the whole workspace, which is why a fenced member is not shown it.",
301
+ ),
297
302
  // Responsibility, beside provenance: the starter says who asked, this says who answers for it now. Absent means
298
303
  // nobody has claimed it, which is what a program's or an automation's conversation is until somebody does.
299
304
  owner: SessionOwnerSchema.optional().describe(
@@ -619,33 +624,15 @@ export const AgentAutoLandSchema = z.object({
619
624
  "Whether its work merges automatically when a turn finishes. Null clears the override and goes back to following the sandbox-wide setting, so a conversation does not sit holding a frozen copy of a default it has quietly stopped following.",
620
625
  ),
621
626
  });
622
- // Same `null`-clears-the-override shape as autoLand, for this conversation's own outage-resume posture.
623
- export const AgentResumeAfterOutageSchema = z.object({
624
- id: z.string().min(1).describe("Which conversation."),
625
- resumeAfterOutage: z
626
- .boolean()
627
- .nullable()
628
- .describe("Whether it retries by itself when the model provider was what failed. Null clears the override back to the sandbox-wide setting."),
629
- });
630
- // Same three-state override, for the limit blocker; written by the card's own offer when a limit strands a turn.
631
- export const AgentResumeAfterLimitSchema = z.object({
627
+ // Same `null`-clears-the-override shape as autoLand, for this conversation's own answer to one ending's question.
628
+ // One route rather than one per ending: they are the same decision asked about different walls, and three near-identical
629
+ // verbs is how the surfaces drifted apart in the first place.
630
+ export const AgentBreakPolicySchema = z.object({
632
631
  id: z.string().min(1).describe("Which conversation."),
633
- resumeAfterLimit: z
634
- .boolean()
635
- .nullable()
636
- .describe(
637
- "Whether the turn a spent allowance refused is sent again by itself once the window reopens. Null clears the override back to the sandbox-wide setting.",
638
- ),
639
- });
640
- // Same three-state override, for moving a held turn to another account with room.
641
- export const AgentMoveAfterLimitSchema = z.object({
642
- id: z.string().min(1).describe("Which conversation."),
643
- moveAfterLimit: z
644
- .boolean()
645
- .nullable()
646
- .describe(
647
- "Whether the turn a spent allowance refused is moved to another connected account of the same provider that has room, as soon as the refusal lands. Null clears the override back to the sandbox-wide setting.",
648
- ),
632
+ ending: TurnBreakSchema.describe("Which wall this answers for: a spent usage limit, a provider outage, or a turn that stopped short."),
633
+ policy: TurnBreakPolicySchema.nullable().describe(
634
+ "What happens next for that ending. `wait` holds the turn for a press; `retry` re-runs it on a bounded ladder (outage, stop); `resend` sends it again at the published reset and `move` also tries another account with room (limit only). An answer the ending does not allow is refused. Null clears the override and goes back to following the sandbox-wide policy, so a conversation does not sit holding a frozen copy of a default it has quietly stopped following.",
635
+ ),
649
636
  });
650
637
  export const AgentFileDiffQuerySchema = z.object({
651
638
  id: z.string().min(1).describe("Which conversation."),
@@ -0,0 +1,40 @@
1
+ import { PUBLIC_DIR, STATE_DIR } from "@intentic/constants";
2
+ import { describe, expect, test } from "vitest";
3
+ import { AreaFolderSchema } from "./areas.js";
4
+
5
+ // What an area may name. These refusals are load-bearing rather than cosmetic: an area's folders become the fence
6
+ // every file route refuses on, so they are the whole of what keeps a grant below maintainer out of the config that
7
+ // decides what agents may do.
8
+ describe("AreaFolderSchema", () => {
9
+ test("an ordinary folder, at any depth, is an area", () => {
10
+ expect(AreaFolderSchema.safeParse("support").success).toBe(true);
11
+ expect(AreaFolderSchema.safeParse("finance/reports").success).toBe(true);
12
+ });
13
+
14
+ test("the workspace root is not an area: naming no area at all is what granting everything means", () => {
15
+ expect(AreaFolderSchema.safeParse(".").success).toBe(false);
16
+ expect(AreaFolderSchema.safeParse("/").success).toBe(false);
17
+ });
18
+
19
+ test("a folder that climbs out of the workspace is refused however it is spelled", () => {
20
+ expect(AreaFolderSchema.safeParse("../etc").success).toBe(false);
21
+ expect(AreaFolderSchema.safeParse("support/../..").success).toBe(false);
22
+ });
23
+
24
+ test("the sandbox's own configuration and its outbox can never be named", () => {
25
+ for (const folder of [
26
+ STATE_DIR,
27
+ `${STATE_DIR}/config`,
28
+ `${STATE_DIR}/config/hooks`,
29
+ PUBLIC_DIR,
30
+ `${PUBLIC_DIR}/site`,
31
+ ]) {
32
+ expect(AreaFolderSchema.safeParse(folder).success, folder).toBe(false);
33
+ }
34
+ });
35
+
36
+ test("a folder that merely starts with one of those names is ordinary", () => {
37
+ expect(AreaFolderSchema.safeParse("publications").success).toBe(true);
38
+ expect(AreaFolderSchema.safeParse("support/public-notes").success).toBe(true);
39
+ });
40
+ });
@@ -0,0 +1,49 @@
1
+ import { z } from "zod";
2
+ import { foldPath, isSandboxPath } from "../policy/fence-paths.js";
3
+ import { entryId } from "./internal.js";
4
+
5
+ // A named part of the workspace: the unit a person's reach is granted in. Named rather than listed per member because
6
+ // a folder list on every row makes each new folder an edit per member, and two rows meant to see the same thing drift
7
+ // apart the first time one is updated and the other isn't.
8
+ // An area is not a credential and holds none; what it holds is a decision about who sees which folders, which is why
9
+ // the file is tracked and a change to it shows up in review.
10
+
11
+ // Workspace-relative, forward-slash, no climb. Checked rather than rewritten: a transform here would make the route's
12
+ // wire shape inexpressible, and two builds compare surfaces by that shape. The route folds what it writes instead, so
13
+ // two spellings of one folder still cannot make two entries.
14
+ export const AreaFolderSchema = z
15
+ .string()
16
+ .min(1)
17
+ .max(200)
18
+ .refine((raw) => (foldPath(raw) ?? "") !== "", {
19
+ message: "a folder is workspace-relative and inside the workspace; the workspace root is what naming no area already means",
20
+ })
21
+ // A fence is what a writer's every file route refuses on, so an area naming the control plane would be the one way
22
+ // a grant below maintainer could reach the config that decides what agents may do, or publish to the internet.
23
+ .refine((raw) => !isSandboxPath(raw), {
24
+ message: "an area cannot name the sandbox's own configuration or its public outbox",
25
+ });
26
+
27
+ export const AreaSchema = z.object({
28
+ id: entryId.describe("The area's id, the name a member row points at."),
29
+ label: z.string().max(60).optional().describe("What to call it on screen. Absent falls back to the id, which somebody chose anyway."),
30
+ brief: z
31
+ .string()
32
+ .max(200)
33
+ .optional()
34
+ .describe("What this part of the workspace is, in one line, so whoever grants it can tell what they are handing over."),
35
+ folders: z
36
+ .array(AreaFolderSchema)
37
+ .min(1)
38
+ .max(50)
39
+ .describe(
40
+ "The folders it admits, workspace-relative. At least one: an area naming nothing would be a grant with no reader, and the way to grant everything is to name no area at all.",
41
+ ),
42
+ });
43
+ export type Area = z.infer<typeof AreaSchema>;
44
+
45
+ export const AreasListSchema = z.object({
46
+ areas: z.array(AreaSchema).describe("Every named part of the workspace this sandbox grants access in."),
47
+ });
48
+
49
+ export const AreaIdParamSchema = z.object({ id: entryId.describe("Which area.") });
@@ -4,6 +4,7 @@ import { AgentOriginSchema, ModelPinSchema } from "./agent.js";
4
4
  import { AgentSummarySchema } from "./agents.js";
5
5
  import { entryId } from "./internal.js";
6
6
  import { IssuesConfigSchema } from "./issues.js";
7
+ import { ZoneSchema } from "../time/zone.js";
7
8
  // An automation wakes the agent: the daemon fires each enabled one on its trigger, runs the optional guard command
8
9
  // (non-zero exit skips the wake), then runs one turn with the prompt. The manifest is user config; run history is
9
10
  // daemon-recorded.
@@ -60,6 +61,13 @@ export const TriggerSchema = z.discriminatedUnion("kind", [
60
61
  z.object({
61
62
  kind: z.literal("schedule").describe("On a clock."),
62
63
  cron: z.string().min(1).describe("When, in cron notation."),
64
+ // A cron is a WALL-CLOCK RULE: "43 20 * * *" is not a moment, it is 20:43 on some clock, and which clock is
65
+ // not written in it. The composer sends the reader's own zone here; absent, the sandbox's `timezone` setting
66
+ // answers, and absent that, UTC. Stored per automation rather than only globally so one chore can keep a
67
+ // colleague's hours, or a market's, without moving everything else.
68
+ tz: ZoneSchema.optional().describe(
69
+ "Which clock the times in the cron mean, as a zone name like Europe/Warsaw. Leave it out to use the sandbox's own setting, which is what you want unless this one chore belongs to a different place.",
70
+ ),
63
71
  // Fires only once at least this many other sessions have started since this automation's last wake; a due run
64
72
  // short of that is recorded as skipped.
65
73
  afterSessions: z
@@ -3,7 +3,6 @@
3
3
  import { z } from "zod";
4
4
  import { ExitConfigSchema } from "./exit.js";
5
5
  import { entryId } from "./internal.js";
6
- import { ServiceKindSchema } from "./inventory.js";
7
6
  import { NetdiskConfigSchema } from "./netdisk.js";
8
7
  import { VpnConfigSchema } from "./vpn.js";
9
8
  // One-way: the browser extension bundles webext.js alone, so nothing there may reach back into this file.
@@ -15,8 +14,6 @@ export const CapabilityKindSchema = z.enum([
15
14
  "devops",
16
15
  "monorepo",
17
16
  "mcp",
18
- "service",
19
- "integration",
20
17
  "cli",
21
18
  "plugin",
22
19
  "extension",
@@ -42,18 +39,6 @@ export const McpConfigSchema = z.object({
42
39
  url: z.url().describe("Where the tool server answers."),
43
40
  token: z.string().optional().describe("The credential it needs, if any. Stored, never echoed back."),
44
41
  });
45
- export const ServiceConfigSchema = z.object({
46
- service: ServiceKindSchema.describe("Which service to provision."),
47
- domain: z.string().min(1).describe("The address it should answer on."),
48
- on: z.string().min(1).describe("Which machine to put it on."),
49
- expose: z.string().min(1).describe("How it should be reachable."),
50
- });
51
- // External-app credential injected into deployed apps (i.have.stripe → STRIPE_API_KEY), not agent-facing like `cli`.
52
- // Closed, unlike `cli`: it becomes an `i.have.<provider>` deploy.config.ts entry, so the vocabulary belongs to the
53
- // deploy engine, not an extension.
54
- export const IntegrationConfigSchema = z.object({
55
- provider: z.literal("stripe").describe("Which outside service's credential to make available to deployed apps."),
56
- });
57
42
  // Gives the agent an authenticated CLI tool: credential plus any non-secret URL, injected into the agent's env each
58
43
  // turn, taught via an .agents/skills/<id> cheatsheet. Provider fields are data in an extension's
59
44
  // `contributes.capabilities`, validated at add-time, not by this schema.
@@ -234,6 +219,43 @@ export const LOCAL_MODEL_WINDOW_DEFAULT: LocalModelWindow = "65536";
234
219
  // ceiling no shipped GGUF was trained for it.
235
220
  export const LOCAL_MODEL_WINDOW_MIN = 2048;
236
221
  export const LOCAL_MODEL_WINDOW_MAX = 1_048_576;
222
+ // q8_0 KV cost per token, the rate every window price derives from: 64 KiB, so the rungs come out at exactly 1, 2, 4
223
+ // and 8 GiB. One rate rather than a per-model one, because the true cost varies ~2x across the list (50–60 KB/token
224
+ // measured) and a per-row figure is arithmetic somebody redoes by hand on every model added. Rounded UP into that band
225
+ // rather than down: over-reserving costs a rung, under-reserving costs an allocation failure a card had promised
226
+ // against.
227
+ export const LOCAL_MODEL_KV_BYTES_PER_TOKEN = 65_536;
228
+ // What a rung honestly serves. `instant` is the one the connect view prefetches — it downloads in under a minute and
229
+ // cannot drive a full agent turn, so no surface may sell it as one; `work` is what that view recommends for real use.
230
+ export type LocalModelTier = "instant" | "work";
231
+ export interface LocalModelChoice {
232
+ // Hugging Face owner/repo/file.gguf, exactly as LocalModelConfig.model carries it.
233
+ readonly id: string;
234
+ readonly label: string;
235
+ // The published file's own size, not a rounded guess: the card's label, the fit arithmetic and the download
236
+ // estimate all read this, so a model cannot be priced two ways.
237
+ readonly weightsBytes: number;
238
+ readonly tier: LocalModelTier;
239
+ }
240
+ // The curated list, smallest first. ponytail: these bytes are the Hugging Face tree API's answer for each file — the
241
+ // hand-written labels that predated them overstated gemma-4 12B by 2x and Qwen3.8 27B by a third, which is the class of
242
+ // drift a machine-readable size exists to end.
243
+ export const LOCAL_MODELS: readonly LocalModelChoice[] = [
244
+ { id: "unsloth/Qwen3.5-2B-GGUF/Qwen3.5-2B-Q4_K_M.gguf", label: "Qwen3.5 2B", weightsBytes: 1_280_835_840, tier: "instant" },
245
+ {
246
+ id: "unsloth/Phi-4-mini-instruct-GGUF/Phi-4-mini-instruct-Q4_K_M.gguf",
247
+ label: "Phi-4-mini 3.8B",
248
+ weightsBytes: 2_491_874_272,
249
+ tier: "work",
250
+ },
251
+ { id: "unsloth/Qwen3.5-9B-GGUF/Qwen3.5-9B-Q4_K_M.gguf", label: "Qwen3.5 9B", weightsBytes: 5_680_522_464, tier: "work" },
252
+ { id: "unsloth/gemma-4-12b-it-GGUF/gemma-4-12b-it-Q4_K_M.gguf", label: "Gemma 4 12B", weightsBytes: 7_121_861_440, tier: "work" },
253
+ { id: "unsloth/Qwen3.8-27B-GGUF/Qwen3.8-27B-UD-Q4_K_M.gguf", label: "Qwen3.8 27B", weightsBytes: 16_464_440_224, tier: "work" },
254
+ ];
255
+ export const localModelChoice = (id: string): LocalModelChoice | undefined => LOCAL_MODELS.find((choice) => choice.id === id);
256
+ // Exactly one row carries the instant tier; the fit module and the prefetch route take it from here rather than
257
+ // repeating its id, so the prefetched bytes and the offered option cannot name different files.
258
+ export const LOCAL_MODEL_INSTANT: LocalModelChoice = LOCAL_MODELS.find((choice) => choice.tier === "instant")!;
237
259
  export const LocalModelConfigSchema = z.object({
238
260
  model: z.string().min(1),
239
261
  gpu: z.enum(["on", "off"]).default("off"),
@@ -266,8 +288,6 @@ export const WalletConfigSchema = z.object({
266
288
  });
267
289
  export type WalletConfig = z.infer<typeof WalletConfigSchema>;
268
290
  export type McpConfig = z.infer<typeof McpConfigSchema>;
269
- export type ServiceConfig = z.infer<typeof ServiceConfigSchema>;
270
- export type IntegrationConfig = z.infer<typeof IntegrationConfigSchema>;
271
291
  export type CliConfig = z.infer<typeof CliConfigSchema>;
272
292
  export type PluginConfig = z.infer<typeof PluginConfigSchema>;
273
293
  export type ExtensionConfig = z.infer<typeof ExtensionConfigSchema>;
@@ -283,8 +303,6 @@ export const CapabilitySchema = z.discriminatedUnion("kind", [
283
303
  // operator panel.
284
304
  z.object({ id: entryId, kind: z.literal("monorepo"), config: z.object({}) }),
285
305
  z.object({ id: entryId, kind: z.literal("mcp"), config: McpConfigSchema }),
286
- z.object({ id: entryId, kind: z.literal("service"), config: ServiceConfigSchema }),
287
- z.object({ id: entryId, kind: z.literal("integration"), config: IntegrationConfigSchema }),
288
306
  z.object({ id: entryId, kind: z.literal("cli"), config: CliConfigSchema }),
289
307
  z.object({ id: entryId, kind: z.literal("plugin"), config: PluginConfigSchema }),
290
308
  z.object({ id: entryId, kind: z.literal("extension"), config: ExtensionConfigSchema }),
@@ -45,7 +45,11 @@ export const PersonaPowersSchema = z.object({
45
45
  });
46
46
  export type PersonaPowers = z.infer<typeof PersonaPowersSchema>;
47
47
  // `folders` only refuses file-tool calls outside it; it stops a misread instruction, not a shell. The container is the
48
- // real workspace-wide fence.
48
+ // real workspace-wide fence, and a conversation started by a fenced person is narrower still — the turn works inside
49
+ // this persona's folders AND that person's areas, never the wider of the two.
50
+ // A plain folder list rather than a named area (schemas/areas.ts), because the two answer different questions: a
51
+ // persona's fence is written once for that persona, while an area is a grant several people hold and one edit has to
52
+ // move all of them.
49
53
  // No placement field, by decision: every session already opens in its own private copy, never the shared tree.
50
54
  export const PersonaWorkspaceSchema = z.object({
51
55
  // Absent means the workspace root.
@@ -192,7 +196,9 @@ export const PersonaSchema = z.object({
192
196
  .string()
193
197
  .max(200)
194
198
  .optional()
195
- .describe("What this persona is for, in one line. A new chat is routed onto a persona by this sentence, and the Personas page shows it under the name."),
199
+ .describe(
200
+ "What this persona is for, in one line. A new chat is routed onto a persona by this sentence, and the Personas page shows it under the name.",
201
+ ),
196
202
  powers: PersonaPowersSchema.optional().describe(
197
203
  "What a conversation wearing it may do. Absent means the full toolbox, so a card written before this existed behaves exactly as it did.",
198
204
  ),
@@ -221,13 +227,18 @@ export const PersonaSchema = z.object({
221
227
  export type Persona = z.infer<typeof PersonaSchema>;
222
228
  // Ladder filtered to connected providers, deduplicated. Empty (absent, or every provider disconnected) means the card
223
229
  // has no opinion — the caller decides, not "run nothing".
224
- export const personaModels = (card: Pick<Persona, "models">, sources: readonly ModelSource[]): readonly ModelPin[] => readyChain(sources, card.models ?? []);
230
+ export const personaModels = (card: Pick<Persona, "models">, sources: readonly ModelSource[]): readonly ModelPin[] =>
231
+ readyChain(sources, card.models ?? []);
225
232
  // Asked once per chat, on the message it was sent with; answers with the one card the message belongs to, or none.
226
233
  // `folder`/`paths` are the facts a card's `context`/`startIn` can be matched against that words alone can't supply.
227
234
  export const PersonaRouteAskSchema = z.object({
228
235
  prompt: z.string().min(1).max(20000).describe("The message a new chat is about to open with."),
229
236
  folder: z.string().max(200).optional().describe("The workspace folder the chat was opened in, when it was opened in one."),
230
- paths: z.array(z.string().min(1).max(500)).max(50).default([]).describe("Workspace paths the message names: uploads, @-mentions, the editor's own file."),
237
+ paths: z
238
+ .array(z.string().min(1).max(500))
239
+ .max(50)
240
+ .default([])
241
+ .describe("Workspace paths the message names: uploads, @-mentions, the editor's own file."),
231
242
  });
232
243
  export type PersonaRouteAsk = z.infer<typeof PersonaRouteAskSchema>;
233
244
  export const PersonaRouteSchema = z.object({
@@ -60,9 +60,18 @@ export const UsageTurnSchema = z.object({
60
60
  openingListings: z.number().optional(),
61
61
  // Tool calls before the turn first touched a file it later edited; absent (not zero) if it edited nothing.
62
62
  callsBeforeTarget: z.number().optional(),
63
+ // Calls that ended in error, counted once each however many updates reported it; absent means unmeasured, not a
64
+ // clean turn.
65
+ failedCalls: z.number().optional(),
63
66
  // Arm of the project-map experiment, stable per conversation; mapChars is the note's length when sent.
64
67
  mapArm: z.boolean().optional(),
65
68
  mapChars: z.number().optional(),
69
+ // Arm of the field-notes experiment, stable per conversation; notesChars is what the budget let through.
70
+ notesArm: z.boolean().optional(),
71
+ notesChars: z.number().optional(),
72
+ // Hash of the brief that was sent, recorded on control turns too. Load-bearing rather than decorative: the file is
73
+ // rewritten monthly, so a 30-day window holds two different treatments and pooling them would measure neither.
74
+ notesCohort: z.string().optional(),
66
75
  // What became of pre-turn retrieval on this turn, and how long it took. Assignment and DELIVERY are different
67
76
  // facts: the first version of this mechanism was assigned to every eligible turn and reached four in five of them,
68
77
  // which is the difference between a null result and a mechanism that never ran. Absent means the flag was off, so
@@ -5,6 +5,8 @@ import { z } from "zod";
5
5
  import { CommandJudgeModeSchema } from "../policy/safety-policy.js";
6
6
  import { ModelRoleSchema } from "../models/model-roles.js";
7
7
  import { AdmissionPolicySchema, AdmissionRuleSchema, ModelPinSchema } from "./agent.js";
8
+ import { LimitPolicySchema, RetryPolicySchema } from "./turn-break.js";
9
+ import { ZoneSchema } from "../time/zone.js";
8
10
  // Which prompt base the agent runs before this turn composes anything on top: Intentic's own (default), Claude Code's
9
11
  // preset, or the owner's text. Declared out here since both the daemon and the browser branch on it.
10
12
  export const SystemPromptModeSchema = z.enum(["intentic", "claude", "custom"]);
@@ -202,6 +204,20 @@ export type SkillSwitch = z.infer<typeof SkillSwitchSchema>;
202
204
  // picks rather than failing whole.
203
205
 
204
206
  export const SandboxSettingsSchema = z.object({
207
+ // The zone every WALL-CLOCK RULE in this sandbox is meant in — an automation's cron, and anything else that says
208
+ // "at 09:00" instead of naming an instant. It is not a display preference: nothing formats through it, and an
209
+ // instant on screen is still drawn in the reader's own clock.
210
+ // Empty means nobody has said, and the container's clock (UTC) answers. That is harmless for a sandbox where
211
+ // nothing is scheduled and wrong the moment something is, which is why the editor offers its own zone on first
212
+ // load and the automations screen names this one beside every schedule.
213
+ // An IANA id rather than an offset, because an offset cannot express the summer-time rule that moves "09:00" twice
214
+ // a year, and a chore set in March should still be right in November.
215
+ timezone: z
216
+ .union([z.literal(""), ZoneSchema])
217
+ .default("")
218
+ .describe(
219
+ "Which clock this sandbox's schedules are set by, as a zone name like Europe/Warsaw. Automations that repeat on a clock fire by this, not by the machine's own time. Leave it empty and they fire by UTC, which is almost certainly not what you meant when you typed a time.",
220
+ ),
205
221
  stableSystemPrompt: z
206
222
  .boolean()
207
223
  .default(false)
@@ -270,6 +286,34 @@ export const SandboxSettingsSchema = z.object({
270
286
  .describe(
271
287
  "What share of conversations to open without the map, so the two can be compared. Whole conversations rather than individual turns, because the map is sent once and stays in the conversation's history afterwards.",
272
288
  ),
289
+ // The one composed piece that is WRITTEN rather than derived: a monthly automation rewrites it off the session
290
+ // corpus, so it carries what no scan of the tree can (which commands really work here, what the box can take, how
291
+ // the owner asks for things) and the map keeps carrying what a scan can.
292
+ fieldNotes: z
293
+ .boolean()
294
+ .default(false)
295
+ .describe(
296
+ "Open every turn with a brief on how work actually goes in this sandbox: the traps that cost past sessions calls, the commands that really verify, what the machine can take. Written once a month by an automation that reads back the sessions run here, rather than worked out per turn, because it is drawn from history rather than from the tree. Off by default, since it rides every turn of every conversation.",
297
+ ),
298
+ // Characters, not sections: the file's own ranking decides WHICH sections, this decides HOW MANY fit. Same unit as
299
+ // the project map's ceiling so the two costs read on one scale.
300
+ fieldNotesBudget: z
301
+ .number()
302
+ .int()
303
+ .min(500)
304
+ .max(20000)
305
+ .default(4000)
306
+ .describe(
307
+ "How much of that brief to send. Its sections are ranked, most costly-to-not-know first, and they are taken whole in that order until this runs out — so raising it buys more of the tail, never a fuller version of the same thing.",
308
+ ),
309
+ fieldNotesHoldout: z
310
+ .number()
311
+ .min(0)
312
+ .max(1)
313
+ .default(0)
314
+ .describe(
315
+ "What share of conversations to run without the brief, so the two can be compared. Whole conversations rather than individual turns, because the brief sits in the prompt for the whole session and withholding it from one turn would not take it back.",
316
+ ),
273
317
  // Only the eager background pass; the `fileq` CLI itself is always on PATH regardless, gated only by its own skill.
274
318
  sidecars: z
275
319
  .boolean()
@@ -331,30 +375,20 @@ export const SandboxSettingsSchema = z.object({
331
375
  .describe(
332
376
  "How many days a finished conversation stays on the board before being put away. Zero means never. The one setting here that defaults on, because each card left behind is a real working copy on disk, not just a row.",
333
377
  ),
334
- // Off still records the failure, so the per-conversation resume offer arms normally; nothing is lost, just not
335
- // automatic.
336
- resumeAfterOutage: z
337
- .boolean()
338
- .default(false)
339
- .describe(
340
- "Whether a turn killed by the model provider failing is re-run automatically, backing off between attempts. The sandbox-wide default; any one conversation can say otherwise. Off to begin with, because a retry spends your allowance on a turn you sent once and only you can say whether it was worth paying for twice. Worth turning on for a sandbox whose work mostly happens with nobody in the room.",
341
- ),
342
- // The one resume that waits for a published instant rather than guessing; a limit with no published reset (Grok,
343
- // Cursor) never fires this way at all.
344
- resumeAfterLimit: z
345
- .boolean()
346
- .default(false)
347
- .describe(
348
- "Whether a turn a spent usage limit refused is sent again by itself once the allowance reopens. The sandbox-wide default; any one conversation can say otherwise. Off to begin with, because the allowance is your budget and a turn that spends it the second it comes back is not a decision to make for you. Worth turning on for a sandbox whose work mostly happens with nobody in the room.",
349
- ),
350
- // Same provider only; a different provider would retire the session for a saving that isn't one. Composes with
351
- // `resumeAfterLimit` into four postures: hold, wait for reset, move-or-hold, move-or-wait.
352
- moveAfterLimit: z
353
- .boolean()
354
- .default(false)
355
- .describe(
356
- "Whether a turn a spent usage limit refused is moved to another connected account of the same provider that still has room, as soon as the refusal lands. The sandbox-wide default; any one conversation can say otherwise. Off to begin with, because it spends a second account on your behalf. With no account that has room the turn waits as the setting above says.",
357
- ),
378
+ // One answer per ending, never a set of switches over the same event: the chat's own question and these rows are
379
+ // the same question at two scopes. Every ending defaults to `wait`, because a re-run spends the reader's allowance
380
+ // on a turn they sent once.
381
+ limitPolicy: LimitPolicySchema.default("wait").describe(
382
+ "What happens to a turn a spent usage limit refused. `wait` holds it for a press. `resend` sends it again by itself once the allowance reopens, which needs a provider that publishes a reset (Grok and Cursor publish none). `move` also tries another connected account of the same provider that still has room, as soon as the refusal lands, and keeps the reset as its fallback. The sandbox-wide default; any one conversation can say otherwise.",
383
+ ),
384
+ // Off still records the failure, so the per-conversation offer arms normally; nothing is lost, just not automatic.
385
+ outagePolicy: RetryPolicySchema.default("wait").describe(
386
+ "What happens to a turn the model provider's own failure killed. `wait` holds it for a press. `retry` re-runs it on the shared per-provider breaker, backing off between attempts. The sandbox-wide default; any one conversation can say otherwise. Worth `retry` for a sandbox whose work mostly happens with nobody in the room.",
387
+ ),
388
+ // The posture that used to live only in a browser tab; moving it here is what lets it fire with nothing open.
389
+ stopPolicy: RetryPolicySchema.default("wait").describe(
390
+ "What happens to a turn that stopped short with nothing to repair — a hung runtime, a crashed harness. `wait` holds it for a press. `retry` re-runs the held turn on a short ladder, standing down after three tries that got nowhere rather than looping forever.",
391
+ ),
358
392
  limitMoveCarryUnder: z
359
393
  .number()
360
394
  .int()
@@ -369,7 +403,7 @@ export const SandboxSettingsSchema = z.object({
369
403
  .boolean()
370
404
  .default(false)
371
405
  .describe(
372
- "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.",
406
+ "Whether a turn killed by the sandbox restarting is re-run once it comes back. A switch rather than one of the policies above, because a restart is the one ending with nobody watching it, so there is no in-chat question to answer. Off to begin with: 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.",
373
407
  ),
374
408
  // Which repositories' own declarations (`<repo>/.intentic/checks.json`) the owner has switched on, each against the
375
409
  // fingerprint of what was declared when they did. A declaration that has since changed no longer matches its
@@ -430,6 +464,20 @@ export const SandboxSettingsSchema = z.object({
430
464
  .describe("How many levels deep the delegation may go, since a subagent can start subagents of its own."),
431
465
  });
432
466
  export type SandboxSettings = z.infer<typeof SandboxSettingsSchema>;
467
+
468
+ // A browser telling the sandbox which clock IT is on. An offer, not an instruction: see `adoptTimezone`.
469
+ export const TimezoneOfferSchema = z.object({
470
+ timezone: ZoneSchema.describe("The zone the offering machine is in, as an IANA name like Europe/Warsaw."),
471
+ });
472
+ export type TimezoneOffer = z.infer<typeof TimezoneOfferSchema>;
473
+
474
+ // What the sandbox's clock is after the offer, and whether the offer is what set it. `adopted: false` with a zone
475
+ // back means somebody had already chosen, which is the normal answer for every browser after the first.
476
+ export const TimezoneStateSchema = z.object({
477
+ timezone: z.string().describe("The zone this sandbox's schedules are now read in. Empty only if none could be resolved."),
478
+ adopted: z.boolean().describe("Whether this call is what set it. False means it was already answered and the stored zone stands."),
479
+ });
480
+ export type TimezoneState = z.infer<typeof TimezoneStateSchema>;
433
481
  // Read live from the installed CLI (preset-prompt.ts), not a stored transcription. `version` is the CLI build it came
434
482
  // from, so a fork from an older build reads as a snapshot; empty for Intentic's own prompt.
435
483
  export const BuiltinPromptTextSchema = z.object({ text: z.string(), version: z.string() });
@@ -465,9 +513,10 @@ export const SavingsArmSchema = z.object({ turns: z.number(), mean: z.number() }
465
513
  // openingSearches: same, narrowed to before the turn first touched a file.
466
514
  // openingListings: directory listings a turn ran to orient itself (the project map).
467
515
  // callsBeforeTarget: how far a turn walked before touching a file it went on to edit.
516
+ // failedCalls: tool calls that ended in error (the field notes, whose largest section is a failure taxonomy).
468
517
  // Never cost: each mechanism moves one small part of a turn's work, inside the noise of the rest.
469
518
  export const TurnMetricReadingSchema = z.object({
470
- metric: z.enum(["searchCalls", "openingSearches", "openingListings", "callsBeforeTarget"]),
519
+ metric: z.enum(["searchCalls", "openingSearches", "openingListings", "callsBeforeTarget", "failedCalls"]),
471
520
  on: SavingsArmSchema,
472
521
  off: SavingsArmSchema,
473
522
  // Additional control turns to reach a fixed target resolution, not today's delta (which inherits noise and always
@@ -518,11 +567,34 @@ export const DependencySavingsSchema = z.object({
518
567
  updatedAt: z.number().optional(),
519
568
  });
520
569
  export type DependencySavings = z.infer<typeof DependencySavingsSchema>;
570
+ // What the settings row can say about the brief without opening it: whether there is one, how much of it this budget
571
+ // reaches, and whether anything is scheduled to rewrite it. Read off the file and the automation store, never stored.
572
+ export const FieldNotesStatusSchema = z.object({
573
+ // False means the automation has never run (or was never set up) — the ordinary state before the first month.
574
+ present: z.boolean(),
575
+ // Epoch ms the file was last written. Absent when there is no file.
576
+ writtenAt: z.number().optional(),
577
+ // How many ranked sections the current budget reaches, out of how many the file holds. The pair is the point: "5"
578
+ // alone cannot tell a generous budget from a short file.
579
+ ranksSent: z.number().optional(),
580
+ ranksTotal: z.number().optional(),
581
+ // What the brief costs the prompt, in characters, at the current budget.
582
+ chars: z.number().optional(),
583
+ // The monthly rewrite: absent until the owner creates it from the offered template, since an automation names the
584
+ // models it spends and nothing chooses those for them.
585
+ automation: z.enum(["missing", "enabled", "disabled"]),
586
+ // When it next runs, epoch ms; absent when there is nothing scheduled.
587
+ nextRunAt: z.number().optional(),
588
+ // Said out loud rather than shown as "no file": a brief that exists and cannot be read is a broken automation.
589
+ unreadable: z.string().optional(),
590
+ });
591
+ export type FieldNotesStatus = z.infer<typeof FieldNotesStatusSchema>;
521
592
  export const SavingsReportSchema = z.object({
522
593
  input: InputSavingsSchema,
523
594
  search: TurnExperimentSchema.optional(),
524
595
  // Same absence rule as `search`: not measured, never zero.
525
596
  map: TurnExperimentSchema.optional(),
597
+ notes: TurnExperimentSchema.optional(),
526
598
  dependencies: DependencySavingsSchema.optional(),
527
599
  });
528
600
  export type SavingsReport = z.infer<typeof SavingsReportSchema>;
@@ -9,16 +9,17 @@ export const OkSchema = object({
9
9
  .describe("Always true. A route that answers this either did the thing or refused with a status; there is no third outcome to report."),
10
10
  });
11
11
  // Trust tiers, ordered low to high: desk talks to the persona cards it was handed and sees nothing else, viewer
12
- // watches, collaborator's outward actions become requests, maintainer holds the owner's authority but is revocable,
13
- // owner is the one bound identity and not a grant.
14
- export const MemberRoleSchema = zEnum(["desk", "viewer", "collaborator", "maintainer", "owner"]);
12
+ // watches, collaborator's outward actions become requests, writer changes files inside the areas it holds and ships
13
+ // none of them, maintainer holds the owner's authority but is revocable, owner is the one bound identity and not a
14
+ // grant.
15
+ export const MemberRoleSchema = zEnum(["desk", "viewer", "collaborator", "writer", "maintainer", "owner"]);
15
16
  export type MemberRole = z.infer<typeof MemberRoleSchema>;
16
17
  // Roles an invite can grant: everything but owner, which binds at first sign-in and is never granted.
17
- export const GrantedRoleSchema = zEnum(["desk", "viewer", "collaborator", "maintainer"]);
18
+ export const GrantedRoleSchema = zEnum(["desk", "viewer", "collaborator", "writer", "maintainer"]);
18
19
  export type GrantedRole = z.infer<typeof GrantedRoleSchema>;
19
20
  // Single source for role order; every surface that gates on a role reads this ranking. A desk is below every
20
21
  // floor: its routes are an allowlist of its own (auth/role-floor.ts deskReach), never a floor it can clear.
21
- const MEMBER_ROLE_RANK: Record<MemberRole, number> = { desk: 0, viewer: 1, collaborator: 2, maintainer: 3, owner: 4 };
22
+ const MEMBER_ROLE_RANK: Record<MemberRole, number> = { desk: 0, viewer: 1, collaborator: 2, writer: 3, maintainer: 4, owner: 5 };
22
23
  export const roleAtLeast = (role: MemberRole, floor: MemberRole): boolean => MEMBER_ROLE_RANK[role] >= MEMBER_ROLE_RANK[floor];
23
24
  // Rotating a door credential (event webhook, release gate, bug intake): the old value stops working immediately; the
24
25
  // new one is shown once to be saved.