@intentic/sandbox-contract 1.217.0 → 1.218.1

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.
@@ -37,9 +37,9 @@ import type { StateFile } from "./state-portability.js";
37
37
  *
38
38
  * `path` is workspace-root-relative, forward-slash — the space `workspaceChanged` paths arrive in. Matching is
39
39
  * by PREFIX, which lets one entry cover three shapes without a second matching rule:
40
- * - an exact file `.intentic/settings.json`
41
- * - a directory `.intentic/drafts/` (one file per draft)
42
- * - a name family `.intentic/environment.custom.` (…Dockerfile and anything later named beside it)
40
+ * - an exact file `.intentic/config/settings.json`
41
+ * - a directory `.intentic/config/drafts/` (one file per draft)
42
+ * - a name family `.intentic/config/environment.custom.` (…Dockerfile and anything later named beside it)
43
43
  * A directory entry keeps its trailing slash so it can never prefix-match a sibling file. Entries may NEST —
44
44
  * see stateFileFor, which resolves the longest match rather than the first. */
45
45
  export interface WorkspaceStateFile extends StateFile {
@@ -94,6 +94,26 @@ export interface WorkspaceStateFile extends StateFile {
94
94
  * hand-keeping a deny list that goes stale the day a store is added (which is how a 98 kB loop ledger and
95
95
  * whole third-party extension checkouts ended up ranking in code search). */
96
96
  readonly authored?: true;
97
+ /* WHETHER DESKTOP-SYNC COPIES THIS DOWN — the fifth question, and the one `portability` cannot answer even
98
+ * though it looks like it should.
99
+ *
100
+ * Portability asks whether a piece of state may RESTORE into a different sandbox. Backup asks whether it may
101
+ * be COPIED to the owner's own machine so the loss of this sandbox is not the loss of the work. Those read as
102
+ * the same question and are not: `members.json` is the clearest case, and its own entry argues the half that
103
+ * was already written down — an access list that TRAVELLED would let a source sandbox hand itself the
104
+ * target's ownership, which is why it may never be `carry`. None of that reasoning says the owner may not
105
+ * hold a copy of who could drive their own sandbox. Conflating the two is what made the sync ignore the state
106
+ * dir wholesale, so a sandbox that went away took every persona, skill, draft and transcript with it.
107
+ *
108
+ * SO IT DERIVES, and this flag exists only to carve out the entry where the derivation is wrong. The default
109
+ * (backedUp below) is `carry` plus `identity`: ordinary state and the small records that bind this sandbox to
110
+ * its owner. `derived` is excluded for size — it is the caches, the checkouts and the browser profiles, all
111
+ * rebuildable and all bulk — and `secret` is excluded because a credential's blast radius is the number of
112
+ * places it exists, and a laptop is one more place.
113
+ *
114
+ * `false` is the only value: an entry either accepts the derived answer or opts out of the copy, and there is
115
+ * no entry that needs opting IN against its class. Anything opting out says why on the entry. */
116
+ readonly backup?: false;
97
117
  /* WHO BUILDS THIS TREE, when it is not the daemon — an extension, pnpm, another process entirely. Declared
98
118
  * on the entry because the coverage guard's second direction ("every declared entry is built somewhere in
99
119
  * the daemon") is only meaningful for entries the daemon owns: one it can never build must say who does, or
@@ -134,7 +154,7 @@ const STATE_FILES = [
134
154
  * credential — Komodo's api key beside its api secret, which its own connector card calls "like a database
135
155
  * user" — is echoed, and therefore lands in the diff exactly as a database username would. */
136
156
  {
137
- path: ".intentic/capabilities.json",
157
+ path: ".intentic/config/capabilities.json",
138
158
  invalidates: ["capabilities", "environment", "panels", "manifests"],
139
159
  portability: "carry",
140
160
  versioned: true,
@@ -145,14 +165,14 @@ const STATE_FILES = [
145
165
  * it travels because a decision about what this workspace does NOT need is as much the owner's as the
146
166
  * connections themselves — an export that dropped it would greet them on the target with the same
147
167
  * suggestions they had already dismissed. Holds no credential: it is a card name and a file path. */
148
- { path: ".intentic/capability-dismissals.json", invalidates: ["capabilities"], portability: "carry", versioned: true },
168
+ { path: ".intentic/config/capability-dismissals.json", invalidates: ["capabilities"], portability: "carry", versioned: true },
149
169
 
150
170
  /* The secret use ledger — one row per moment the agent's exits spent a stored secret (a `{{secret:name}}`
151
171
  * reference resolved into a shell command, a value typed into a browser field), joined onto the secrets
152
172
  * inventory as each entry's "last used" (sandbox's secrets/secret-uses.ts). Holds names and destinations,
153
173
  * never values — which is why it may `carry`: like the automations' run ledger, a use history is about the
154
174
  * secrets, and an export that dropped it would arrive claiming none had ever been touched. */
155
- { path: ".intentic/secret-uses.json", invalidates: ["secrets"], portability: "carry" },
175
+ { path: ".intentic/records/secret-uses.json", invalidates: ["secrets"], portability: "carry" },
156
176
 
157
177
  /* The named personas this sandbox shows the outside world — which connected accounts each one speaks for,
158
178
  * what a session wearing it may do, where it works (schemas.ts PersonaSchema). It invalidates `capabilities` as well as
@@ -166,7 +186,7 @@ const STATE_FILES = [
166
186
  * argument it was carved out on — a card is configuration, holds no secret, and belongs in review — is the
167
187
  * one `versioned` now generalises to the rest of the config slice (personas/personas-store.ts argues it at
168
188
  * length, and its reasoning is why the flag exists rather than a second hand-kept list). */
169
- { path: ".intentic/personas.json", invalidates: ["personas", "capabilities", "manifests"], portability: "carry", versioned: true },
189
+ { path: ".intentic/config/personas.json", invalidates: ["personas", "capabilities", "manifests"], portability: "carry", versioned: true },
170
190
 
171
191
  /* The overlay Dockerfile, four files that a single `.intentic/environment.` prefix used to cover. They are
172
192
  * split here because they answer PORTABILITY differently while answering invalidation identically, and the
@@ -178,22 +198,22 @@ const STATE_FILES = [
178
198
  * not be on (see composeEnvironment's baseImageOf);
179
199
  * - the proposal and the per-tool drafts under environment.d/ are the agent's pending requests, which the
180
200
  * owner has not answered yet; they travel so the question survives the move. */
181
- { path: ".intentic/environment.custom.Dockerfile", invalidates: ["environment"], portability: "carry", versioned: true },
182
- { path: ".intentic/environment.Dockerfile", invalidates: ["environment"], portability: "carry", versioned: true },
183
- { path: ".intentic/environment.d/", invalidates: ["environment"], portability: "carry", versioned: true },
201
+ { path: ".intentic/config/environment.custom.Dockerfile", invalidates: ["environment"], portability: "carry", versioned: true },
202
+ { path: ".intentic/config/environment.Dockerfile", invalidates: ["environment"], portability: "carry", versioned: true },
203
+ { path: ".intentic/config/environment.d/", invalidates: ["environment"], portability: "carry", versioned: true },
184
204
  {
185
- path: ".intentic/environment.approved.Dockerfile",
205
+ path: ".intentic/local/environment.approved.Dockerfile",
186
206
  invalidates: ["environment"],
187
207
  portability: "derived",
188
208
  note: "The target composes its own overlay on first boot; rebuild it there to install the tools it names.",
189
209
  },
190
210
 
191
- { path: ".intentic/settings.json", invalidates: ["settings", "manifests"], portability: "carry", versioned: true },
211
+ { path: ".intentic/config/settings.json", invalidates: ["settings", "manifests"], portability: "carry", versioned: true },
192
212
  // The rule table's last-fired stamps, beside the rules themselves. `derived` rather than `carry`: it is a
193
213
  // record of what happened in THIS sandbox, and carrying it to a fresh one would date every rule to work
194
214
  // that machine never did.
195
215
  {
196
- path: ".intentic/rule-firings.json",
216
+ path: ".intentic/local/rule-firings.json",
197
217
  invalidates: ["rule-firings"],
198
218
  portability: "derived",
199
219
  note: "Stamps of when each rule last did something; the new sandbox starts its own record.",
@@ -213,13 +233,13 @@ const STATE_FILES = [
213
233
  * `postedAt`/`postedUrl` stamped at the end), and it is KEPT afterwards rather than consumed — so tracking
214
234
  * yields a durable record instead of the add/delete churn a queue would produce. Nothing in a draft is a
215
235
  * credential: it is a platform, a target URL and the body itself, all of it bound for publication anyway. */
216
- { path: ".intentic/drafts/", invalidates: ["drafts"], portability: "carry", versioned: true, authored: true },
236
+ { path: ".intentic/config/drafts/", invalidates: ["drafts"], portability: "carry", versioned: true, authored: true },
217
237
  // ---- declared by the extension that renders them (contributes.files), not here ----
218
238
  // The path is the DAEMON's (automations-store writes both), the query keys are the intentic.automations
219
239
  // extension's. It declares them in its own manifest and the browser unions the two lists, so uninstalling
220
240
  // the extension takes its invalidations with it instead of leaving a rule for a view that no longer exists.
221
241
  {
222
- path: ".intentic/automations.json",
242
+ path: ".intentic/config/automations.json",
223
243
  invalidates: [],
224
244
  why: "Declared by the intentic.automations extension's contributes.files — `automations` is its query key, not core's.",
225
245
  portability: "carry",
@@ -239,13 +259,13 @@ const STATE_FILES = [
239
259
  * rather than inherited, because the row renders its run history from this file now: without its own entry
240
260
  * a completed run would stop refreshing the view the moment it stopped living in automations.json. */
241
261
  {
242
- path: ".intentic/automation-runs.json",
262
+ path: ".intentic/records/automation-runs.json",
243
263
  invalidates: [],
244
264
  why: "Declared by the intentic.automations extension's contributes.files — `automations` is its query key, not core's.",
245
265
  portability: "carry",
246
266
  },
247
267
  {
248
- path: ".intentic/approvals/",
268
+ path: ".intentic/records/approvals/",
249
269
  invalidates: [],
250
270
  why: "Declared by the intentic.automations extension's contributes.files — `automation-approvals` is its query key, not core's.",
251
271
  portability: "carry",
@@ -256,7 +276,7 @@ const STATE_FILES = [
256
276
  * evidence about this workspace, so `carry` like the run ledgers: an export that dropped it would arrive
257
277
  * claiming no chore had ever been checked. */
258
278
  {
259
- path: ".intentic/chores/",
279
+ path: ".intentic/records/chores/",
260
280
  invalidates: [],
261
281
  why: "Declared by the intentic.maintenance extension's contributes.files — `maintenance-report`/`maintenance-runs` are its query keys, not core's.",
262
282
  portability: "carry",
@@ -266,7 +286,7 @@ const STATE_FILES = [
266
286
  * these are draft READMEs, drafts-shaped in every way that matters, and "find the staged page about X" is
267
287
  * as ordinary a search as finding a post draft. */
268
288
  {
269
- path: ".intentic/docs/",
289
+ path: ".intentic/config/docs/",
270
290
  invalidates: [],
271
291
  why: "Declared by the intentic.documentation extension's contributes.files — `documentation`/`documentation-runs` are its query keys, not core's.",
272
292
  portability: "carry",
@@ -280,17 +300,17 @@ const STATE_FILES = [
280
300
  * run surfaces have: the scheduler writes the ledger several times per step and nothing polls for it.
281
301
  * The runs file invalidates `workflows` too, because GET /workflows embeds each design's runs
282
302
  * (WorkflowSummary) — a settled step changes that answer as surely as an edited design does. */
283
- { path: ".intentic/workflows.json", invalidates: ["workflows"], portability: "carry", versioned: true },
284
- { path: ".intentic/workflow-runs.json", invalidates: ["workflows", "workflow-runs"], portability: "carry" },
303
+ { path: ".intentic/config/workflows.json", invalidates: ["workflows"], portability: "carry", versioned: true },
304
+ { path: ".intentic/records/workflow-runs.json", invalidates: ["workflows", "workflow-runs"], portability: "carry" },
285
305
  /* The SAVED loops, which are a manifest and so the opposite of the ledger below them: a handful of entries
286
306
  * a person authors, read by two surfaces at once — the workflows page that owns them, and every chat
287
307
  * composer's loop picker. Those two are in different windows as often as not (a popped-out chat is its own
288
308
  * window), so an edit made on the page has to reach a picker nobody is going to think to reopen. A CORE key
289
309
  * rather than the workflows extension's, for the reason the workflow designs beside it are: the composer
290
310
  * lists saved loops whether or not that extension is switched on. */
291
- { path: ".intentic/loop-designs.json", invalidates: ["loop-designs"], portability: "carry", versioned: true },
311
+ { path: ".intentic/config/loop-designs.json", invalidates: ["loop-designs"], portability: "carry", versioned: true },
292
312
  {
293
- path: ".intentic/loops.json",
313
+ path: ".intentic/records/loops.json",
294
314
  invalidates: [],
295
315
  why: "Ralph loops and their iteration history. Nothing observes it: where a RUNNING loop stands rides on the fleet roster (AgentSummary.loop), which the /events stream already pushes about once a second, and a second source invalidating on this file could only ever disagree with the card beside it. The iteration list of an ENDED loop is an on-demand read — nothing renders it until someone opens it (web's useLoops, which holds no query for exactly this reason).",
296
316
  portability: "carry",
@@ -303,15 +323,15 @@ const STATE_FILES = [
303
323
  * query uses would put the drift this table exists to remove straight back into it. Each says which
304
324
  * constraint would have to move first, so the next reader doesn't re-derive it. */
305
325
  {
306
- path: ".intentic/webchat-installs.json",
326
+ path: ".intentic/records/webchat-installs.json",
307
327
  invalidates: [],
308
- why: "Which origins have loaded a Doorbell's widget, written on a 30s flush timer while a customer's site serves page views. The install panel that renders it fetches on open and polls itself while it is on screen, which is the whole window in which the answer changes for anyone. Pushing instead would bill every connected browser a refetch per flush, for a panel almost nobody has open.",
328
+ why: "Which origins have loaded a Front Desk's widget, written on a 30s flush timer while a customer's site serves page views. The install panel that renders it fetches on open and polls itself while it is on screen, which is the whole window in which the answer changes for anyone. Pushing instead would bill every connected browser a refetch per flush, for a panel almost nobody has open.",
309
329
  portability: "carry",
310
330
  },
311
331
  {
312
- path: ".intentic/thread-sessions.json",
332
+ path: ".intentic/records/thread-sessions.json",
313
333
  invalidates: [],
314
- why: "Thread bookkeeping (an inbound thread — a Doorbell visitor, a Discord or Slack channel — → sandbox conversation + provider session), written on EVERY inbound message. Nothing in the browser reads it: what a thread produces is a conversation, and the fleet board already learns about that from the agent registry's own push. Naming a key here would bill every connected browser a refetch per inbound message — the request storm this table's own note warns about — to refresh nothing it can see.",
334
+ why: "Thread bookkeeping (an inbound thread — a Front Desk visitor, a Discord or Slack channel — → sandbox conversation + provider session), written on EVERY inbound message. Nothing in the browser reads it: what a thread produces is a conversation, and the fleet board already learns about that from the agent registry's own push. Naming a key here would bill every connected browser a refetch per inbound message — the request storm this table's own note warns about — to refresh nothing it can see.",
315
335
  portability: "carry",
316
336
  },
317
337
  /* SPLIT, so that "what an extension is configured to do" and "the token it does it with" stop being one file.
@@ -336,7 +356,7 @@ const STATE_FILES = [
336
356
  * that carries can never show one. "Re-enter the credentials" is now the vault's instruction to give, and the
337
357
  * vault is under `.intentic/auth/` — which is skipped, and says so there. */
338
358
  {
339
- path: ".intentic/extension-settings.json",
359
+ path: ".intentic/config/extension-settings.json",
340
360
  invalidates: [],
341
361
  why: "Held in a module-level shallowRef store per extension (web's extensionSettingsStore) with no query observer, and deliberately so: api.settings.get must answer SYNCHRONOUSLY from an extension's first activate() line, and the store outlives every component scope. A module-level QueryObserver is the one shape that would make invalidation refetch, and this app already ruled it out — it detaches on the queryClient.clear() at logout (see useSandbox's sandbox-list mirror). So a remote member's setting edit reaches this browser on its next load, not live.",
342
362
  portability: "carry",
@@ -347,7 +367,7 @@ const STATE_FILES = [
347
367
  * file) shows up here live. It does not re-run the host: activating or retiring an extension is the loader's
348
368
  * reconcile, which the tab's own toggle triggers, so a remote flip takes effect on this browser's next load. */
349
369
  {
350
- path: ".intentic/extension-enablement.json",
370
+ path: ".intentic/config/extension-enablement.json",
351
371
  invalidates: ["extensions"],
352
372
  portability: "carry",
353
373
  versioned: true,
@@ -371,18 +391,18 @@ const STATE_FILES = [
371
391
  * Not a ledger and not bulk: a handful of small authored files per extension, written when someone edits them.
372
392
  * The daemon restarts the backend host on a change here, so an edit is already a consequential event — this
373
393
  * makes it a legible one. */
374
- { path: ".intentic/workspace-extensions/", invalidates: ["extensions"], portability: "carry", versioned: true, authored: true },
394
+ { path: ".intentic/config/workspace-extensions/", invalidates: ["extensions"], portability: "carry", versioned: true, authored: true },
375
395
  /* What the registry comparison found per installed extension (update available / advisory / post-update
376
396
  * health), written by the periodic check and by the update/revert transactions — pushed to the tab because
377
397
  * an advisory that auto-disabled something must not wait for a reload to be seen. */
378
- { path: ".intentic/extension-updates.json", invalidates: ["extensions"], portability: "carry" },
398
+ { path: ".intentic/records/extension-updates.json", invalidates: ["extensions"], portability: "carry" },
379
399
  /* The owner's per-extension update posture (notify / agent / auto, and the advisory opt-out). Carried:
380
400
  * it is a decision about the extension, not about this machine. */
381
- { path: ".intentic/extension-update-policy.json", invalidates: ["extensions"], portability: "carry", versioned: true },
401
+ { path: ".intentic/config/extension-update-policy.json", invalidates: ["extensions"], portability: "carry", versioned: true },
382
402
  /* Carried, because the evidence is about the extension rather than about the machine: an export that dropped
383
403
  * it would arrive claiming every permission was unused, which is worse than arriving with no figures at all. */
384
404
  {
385
- path: ".intentic/extension-usage.json",
405
+ path: ".intentic/records/extension-usage.json",
386
406
  invalidates: [],
387
407
  why: "Which of the routes each extension DECLARED it has actually called — the evidence behind the permissions list on its row. The one entry here whose empty set is a RATE decision rather than an architectural one: every browser with the app open reports its batch on a timer, so wiring this to the `extensions` query would refetch the whole list every few seconds for a figure nobody is watching change. The tab reads it when it loads, which is when anyone is reading it.",
388
408
  portability: "carry",
@@ -399,7 +419,7 @@ const STATE_FILES = [
399
419
  * let a source sandbox hand itself the target's ownership. Widening the guard for this single entry is the
400
420
  * worse trade — it protects every `identity` entry, and most of those ARE credentials. */
401
421
  {
402
- path: ".intentic/members.json",
422
+ path: ".intentic/identity/members.json",
403
423
  invalidates: [],
404
424
  why: "Not this view's source at all: SandboxAccess renders the PLATFORM's invite records (apiClient.invite.list), and this file is the daemon's ENFORCED copy — written first so a grant the enforcer never got is never recorded, then never read back. A change here means the two disagreed, which the write order makes fail-closed rather than stale.",
405
425
  portability: "identity",
@@ -419,7 +439,7 @@ const STATE_FILES = [
419
439
  * therefore the only place the owner is told what to re-enter: the manifests that name those connections
420
440
  * travel, and would otherwise arrive looking complete. */
421
441
  {
422
- path: ".intentic/auth/",
442
+ path: ".intentic/secrets/auth/",
423
443
  invalidates: [],
424
444
  why: "AI-provider credentials and runtime homes, plus the capability and extension-settings secret vaults; each account is rendered through owner-gated provider routes.",
425
445
  portability: "secret",
@@ -430,25 +450,25 @@ const STATE_FILES = [
430
450
  * The memory notes under it (`projects/<slug>/memory/**`) ARE user-facing and the /memory view polls them
431
451
  * every 30s, which is the one place in this table where a poll survives a real change feed being available.
432
452
  * It stays a poll deliberately: the watcher's exclusion is a DESCENT filter, so reaching those notes means
433
- * letting it walk `.intentic/sessions/claude` → `projects` → every project slug. Measured on the live
453
+ * letting it walk `.intentic/records/sessions/claude` → `projects` → every project slug. Measured on the live
434
454
  * workspace that is +119 watched directories against ~593 today (a fifth more), with 314 continuously-
435
455
  * rewritten transcripts inside the newly-watched set, to make ONE memory directory live. Notes change at
436
456
  * agent-turn cadence, so the poll costs a request a minute and the alternative costs a permanent 20% on the
437
457
  * watcher. */
438
458
  {
439
- path: ".intentic/sessions/claude/",
459
+ path: ".intentic/records/sessions/claude/",
440
460
  invalidates: [],
441
461
  why: "Agent session transcripts — see the note above on why the memory notes under it stay polled.",
442
462
  portability: "carry",
443
463
  },
444
464
  {
445
- path: ".intentic/artifacts/",
465
+ path: ".intentic/records/artifacts/",
446
466
  invalidates: [],
447
467
  why: "Durable outputs owned by conversations and extension runs: attachments, browser captures, generated images, acceptance reports, workflow step reports, voice transcripts, and loop ledgers.",
448
468
  portability: "carry",
449
469
  },
450
470
  {
451
- path: ".intentic/cache/",
471
+ path: ".intentic/local/cache/",
452
472
  invalidates: [],
453
473
  why: "Rebuildable indexes and caches — the iq index and its vector sidecar, the whisper model; ignored by the watcher and recreated from carried workspace content.",
454
474
  portability: "derived",
@@ -459,14 +479,14 @@ const STATE_FILES = [
459
479
  * hour-tokens, gateway discovery state: all of it either expires or re-establishes itself, and classifying
460
480
  * the root once is what keeps a token an extension caches tomorrow out of bundles without a second edit. */
461
481
  {
462
- path: ".intentic/runtime/",
482
+ path: ".intentic/local/runtime/",
463
483
  invalidates: [],
464
484
  why: "Extension runtime scratch (watermarks, cached short-lived tokens); nothing renders it and gateways re-derive it.",
465
485
  portability: "derived",
466
486
  outsideWriter: "extensions, through extensionRuntimeDir below",
467
487
  },
468
488
  {
469
- path: ".intentic/tmp/",
489
+ path: ".intentic/local/tmp/",
470
490
  invalidates: [],
471
491
  why: "Scratch that agents and tools leave behind (build logs, demo checkouts); nothing reads it after the turn that wrote it. The state janitor empties it at boot.",
472
492
  portability: "derived",
@@ -477,59 +497,68 @@ const STATE_FILES = [
477
497
  * sources a fresh install rebuilds, which an export must not ship (it reached 1.3 GB on the workspace this
478
498
  * entry was written against). */
479
499
  {
480
- path: ".intentic/.pnpm-store/",
500
+ path: ".intentic/local/.pnpm-store/",
481
501
  invalidates: [],
482
502
  why: "pnpm's content-addressable store, auto-created by installs run from under .intentic; the next install rebuilds it.",
483
503
  portability: "derived",
484
504
  outsideWriter: "pnpm itself, when an install runs from under .intentic",
485
505
  },
486
506
  {
487
- path: ".intentic/newest-run.json",
507
+ path: ".intentic/local/newest-run.json",
488
508
  invalidates: [],
489
509
  why: "The newest daemon version that ever ran this workspace (store/newest-run.ts) — a downgrade tripwire, about THIS sandbox the way rule-firings is.",
490
510
  portability: "derived",
491
511
  note: "The target stamps its own daemon version on first boot.",
492
512
  },
493
513
  {
494
- path: ".intentic/verify.json",
514
+ path: ".intentic/records/verify.json",
495
515
  invalidates: [],
496
516
  why: "The dependency verifier's verdict memory; nothing renders it directly — outcomes reach the owner as activity entries and workspace events.",
497
517
  portability: "carry",
498
518
  },
499
519
  {
500
- path: ".intentic/verify/",
520
+ path: ".intentic/local/verify/",
501
521
  invalidates: [],
502
522
  why: "A running check's wrapper artifacts (log + exit status), read once by the daemon when the panel finishes.",
503
523
  portability: "derived",
504
524
  },
505
525
  {
506
- path: ".intentic/ci.json",
526
+ path: ".intentic/secrets/ci.json",
507
527
  invalidates: [],
508
528
  why: "Webhook secret + conclusion memory; the Pipelines view reads it through /ci/runs, not off disk.",
509
529
  portability: "secret",
510
530
  note: "Re-add the CI webhook on the Pipelines view — its secret is per-sandbox.",
511
531
  },
532
+ /* THE ONE ENTRY THAT OPTS OUT OF THE BACKUP, and the reason the flag exists rather than the rule simply
533
+ * reading `portability !== "derived"`. It is `identity` like the three below it, so the derived answer would
534
+ * copy it down with them — but where those are a name, a workspace id and a role per row, these are tokens
535
+ * that AUTHENTICATE against this sandbox from outside it. Hashed, which lowers the stakes and does not
536
+ * settle them: the point of a backup is to be readable after the thing it backs up is gone, and a file whose
537
+ * only purpose is to admit callers has no business sitting in one. Nothing is lost by leaving it out — the
538
+ * entry's own note already says the tokens must be re-minted on any new sandbox, so a copy could never have
539
+ * been restored anyway. */
512
540
  {
513
- path: ".intentic/control-tokens.json",
541
+ path: ".intentic/identity/control-tokens.json",
514
542
  invalidates: [],
515
543
  why: "Hashed control tokens (the ACP editor bridge, and anything else driving this sandbox from outside), listed on demand by the owner.",
516
544
  portability: "identity",
545
+ backup: false,
517
546
  note: "Mint fresh control tokens — the old ones authenticate against the source sandbox.",
518
547
  },
519
548
  {
520
- path: ".intentic/owner.json",
549
+ path: ".intentic/identity/owner.json",
521
550
  invalidates: [],
522
551
  why: "Bound once on first use; a change here means the sandbox was re-owned, which re-authenticates anyway.",
523
552
  portability: "identity",
524
553
  },
525
554
  {
526
- path: ".intentic/workspace.json",
555
+ path: ".intentic/identity/workspace.json",
527
556
  invalidates: [],
528
557
  why: "The workspace identity, read from the /events hello frame rather than as a file.",
529
558
  portability: "identity",
530
559
  },
531
560
  {
532
- path: ".intentic/templates.json",
561
+ path: ".intentic/config/templates.json",
533
562
  invalidates: [],
534
563
  why: "Scaffold templates, read when the scaffold dialog opens.",
535
564
  portability: "carry",
@@ -540,20 +569,20 @@ const STATE_FILES = [
540
569
  * constantly and versions against its own build, so carrying them ships bulk that the target's Chromium may
541
570
  * refuse anyway. The note is what keeps the loss visible instead of silent. */
542
571
  {
543
- path: ".intentic/browser/",
572
+ path: ".intentic/local/browser/",
544
573
  invalidates: [],
545
574
  why: "Browser-login profiles: Chromium rewrites these constantly. Descent-ignored by the watcher outright.",
546
575
  portability: "derived",
547
576
  note: "Log the agent's browser back into any site it needs — profiles do not travel.",
548
577
  },
549
578
  {
550
- path: ".intentic/extensions/",
579
+ path: ".intentic/local/extensions/",
551
580
  invalidates: [],
552
581
  why: "Extension checkouts — whole git clones. The `extensions` query is driven by the capability manifest above, not by their contents.",
553
582
  portability: "derived",
554
583
  note: "Extensions re-clone from the capability manifest on the target's next reconcile.",
555
584
  },
556
- { path: ".intentic/plugins/", invalidates: [], why: "Agent plugin dirs, read by the SDK's loader each turn.", portability: "carry" },
585
+ { path: ".intentic/records/plugins/", invalidates: [], why: "Agent plugin dirs, read by the SDK's loader each turn.", portability: "carry" },
557
586
  /* THE SKILLS THE OWNER WROTE THEMSELVES, one directory per skill — the source of truth the reconciler copies
558
587
  * into `.agents/skills` for the ones currently switched on (settings.json's `skills` list). It is here rather
559
588
  * than in the loaded folder for the reason the plugin dirs are: that tree holds only what is currently ON,
@@ -562,7 +591,7 @@ const STATE_FILES = [
562
591
  * `versioned`, like the rest of the config slice: a skill changes how the agent behaves, so it earns a diff
563
592
  * in the Changes review and a line in `git log` the same way a rule or a persona does. `carry` for the same
564
593
  * reason — it is text the owner wrote, with no credential in it and nothing about this machine. */
565
- { path: ".intentic/skills/", invalidates: ["skills"], portability: "carry", versioned: true },
594
+ { path: ".intentic/config/skills/", invalidates: ["skills"], portability: "carry", versioned: true },
566
595
  /* ONE FOLDER PER PERSONA — what a session wearing that card is told, and the skills and tools only it gets.
567
596
  * Laid out as a Claude Code plugin (`.claude-plugin/plugin.json`, `skills/`, `agents/`, `commands/`,
568
597
  * `hooks/`, `.mcp.json`) so the runtime's own loader reads it and this daemon parses none of it, exactly as
@@ -576,7 +605,7 @@ const STATE_FILES = [
576
605
  * `versioned` and `carry` for the same reasons the card and the skills above are: it changes how the agent
577
606
  * behaves, it holds no credential, and it belongs in a pull request — which is also what makes it
578
607
  * searchable, since every versioned entry already is. */
579
- { path: ".intentic/personas/", invalidates: ["personas"], portability: "carry", versioned: true },
608
+ { path: ".intentic/config/personas/", invalidates: ["personas"], portability: "carry", versioned: true },
580
609
  ] as const satisfies readonly WorkspaceStateFile[];
581
610
 
582
611
  export const WORKSPACE_STATE_FILES: readonly WorkspaceStateFile[] = STATE_FILES;
@@ -608,6 +637,112 @@ export const SEARCHABLE_STATE_PATHS: readonly string[] = WORKSPACE_STATE_FILES.f
608
637
  (file) => file.path,
609
638
  );
610
639
 
640
+ /* WHAT KIND OF THING THIS IS, in the one word a person browsing the state dir would use — and the axis the
641
+ * DIRECTORY LAYOUT is built on, so the folder you open explains itself before you read a table about it.
642
+ *
643
+ * It is DERIVED, and that is the whole reason it can be trusted. Forty-nine entries already answer three
644
+ * questions between them (is it reviewed, is it authored, does it travel), and those answers turn out to nest
645
+ * perfectly rather than cut across each other: every `versioned` entry is `carry`, every `authored` entry is
646
+ * `carry`, and nothing is both a credential and a thing a person edits. A nested set of answers is exactly what
647
+ * a directory tree can express, which is why five folders can carry rules that used to take five hand-kept path
648
+ * lists — the git exclude, the search allow-list, the sync ignore, the watcher skip, the export bundle.
649
+ *
650
+ * Declaring the group on each entry instead would have made it a fourth independent fact to keep in step with
651
+ * the other three, which is the failure this file exists to argue against. Adding a store still means answering
652
+ * the same three questions it always did; the group, the folder it belongs in, and every rule that reads them
653
+ * follow with no further edit. */
654
+ export type StateGroup =
655
+ /* Reviewed and reviewable: settings, personas, skills, drafts, staged docs, the environment overlay. Tracked
656
+ * by the root repo, searchable, backed up, and carried into a new sandbox. Two of its members (drafts, staged
657
+ * docs) are authored content rather than configuration, and the folder is still called `config` — the word
658
+ * that makes seventeen of the nineteen instantly clear beats one that makes all nineteen vague. */
659
+ | "config"
660
+ /* What HAPPENED here — run ledgers, approvals, chores, transcripts, artifacts. Machine-written, so untracked
661
+ * and unsearchable, but the owner's history all the same: backed up and carried. */
662
+ | "records"
663
+ /* Rebuildable from something that does travel: caches, indexes, extension checkouts, scratch, the composed
664
+ * overlay, browser profiles. Neither backed up nor carried, and the janitor may delete it. `local` in the
665
+ * sense every other tool uses it — belongs to this machine, is not shared, and losing it costs nothing. */
666
+ | "local"
667
+ /* Who owns this sandbox and who may drive it. Backed up so the owner keeps a copy of their own access, never
668
+ * carried — a list that travelled would let a source sandbox claim the target. */
669
+ | "identity"
670
+ /* Credentials. Never backed up; carried only when the owner opts in at export and the bundle records it. */
671
+ | "secrets";
672
+
673
+ /* THE FOLDER EACH GROUP LIVES IN.
674
+ *
675
+ * The group name IS the directory name — one vocabulary, not a name and a translation of it. That is what lets
676
+ * the guard in workspace-state.test.ts check the whole layout with one rule ("every entry sits under its own
677
+ * group's folder") rather than trusting forty-nine literals to have been typed correctly, and it is why renaming
678
+ * a folder is an edit here plus the literals the compiler then points at, with nothing able to half-move.
679
+ *
680
+ * WHICH RULES THE LAYOUT ACTUALLY CARRIES, stated plainly because it is fewer than the tidy version of this
681
+ * story. The sync backup collapses to two folder names (BACKUP_IGNORES) and workspace search to one, because
682
+ * "may the owner keep this" and "is this authored text" are exactly what the grouping sorts on. Two others do
683
+ * NOT collapse, and both are worth knowing about before someone tries:
684
+ * - THE GIT EXCLUDE tracks `versioned`, which is eighteen of `config`'s nineteen. The exception is the staged
685
+ * docs tree: searchable, deliberately untracked (publishing copies those pages into the repo, so tracking
686
+ * the staging copy too would double every one of them). A `config/` prefix would quietly start tracking it.
687
+ * - THE WATCHER skips what churns, which is most of `local` but not all of it: the composed overlay and the
688
+ * rule-firing stamps are `derived` and therefore `local`, and both still feed a view. A `local/` prefix
689
+ * would stop the environment page refreshing when the overlay is recomposed.
690
+ * Both stay derived from the flags instead, which costs a longer generated list and no correctness. The folders
691
+ * are the layout; the flags are still the authority. */
692
+ export const STATE_GROUP_DIR: Readonly<Record<StateGroup, string>> = {
693
+ config: `${STATE_DIR}/config`,
694
+ records: `${STATE_DIR}/records`,
695
+ local: `${STATE_DIR}/local`,
696
+ identity: `${STATE_DIR}/identity`,
697
+ secrets: `${STATE_DIR}/secrets`,
698
+ };
699
+
700
+ // Every group, derived off the folder map so the two can never disagree about how many there are. Declaration
701
+ // order is the order a person should read them in: what you wrote, what happened, what can be thrown away, who
702
+ // owns this, and the keys.
703
+ export const STATE_GROUPS = Object.keys(STATE_GROUP_DIR) as readonly StateGroup[];
704
+
705
+ /* The group each entry falls in. Ordered most-specific-first: a credential is a credential whatever else it is,
706
+ * and only once those are out of the way does "did a person write this" separate the two `carry` groups. */
707
+ export const stateGroupOf = (file: WorkspaceStateFile): StateGroup => {
708
+ switch (file.portability) {
709
+ case "secret":
710
+ return "secrets";
711
+ case "identity":
712
+ return "identity";
713
+ case "derived":
714
+ return "local";
715
+ case "carry":
716
+ return file.versioned === true || file.authored === true ? "config" : "records";
717
+ }
718
+ };
719
+
720
+ // The entries of one group, workspace-root-relative and in declaration order — what each rule that used to keep
721
+ // its own path list now asks for instead.
722
+ export const stateGroupPaths = (group: StateGroup): readonly string[] =>
723
+ WORKSPACE_STATE_FILES.filter((file) => stateGroupOf(file) === group).map((file) => file.path);
724
+
725
+ /* THE SLICE DESKTOP-SYNC COPIES DOWN — ordinary state and the records that bind this sandbox to its owner,
726
+ * minus anything that opted out (see `backup` on the interface).
727
+ *
728
+ * The sync used to ignore `.intentic` WHOLE, which is the same conflation the `backup` flag exists to undo: the
729
+ * dir holds credentials, so the dir was excluded, so a sandbox going away also took every persona, skill,
730
+ * automation, draft and transcript the owner had. This is the list that makes the owner's machine an actual
731
+ * backup instead of a copy of the source tree only.
732
+ *
733
+ * It is deliberately NOT the same as the export bundle. A bundle asks what may be reconstituted somewhere else;
734
+ * this asks what the owner may keep. `ownership` is the entries where those differ, and it is in here. */
735
+ export const BACKED_UP_STATE_PATHS: readonly string[] = WORKSPACE_STATE_FILES.filter(
736
+ (file) => file.backup !== false && (file.portability === "carry" || file.portability === "identity"),
737
+ ).map((file) => file.path);
738
+
739
+ /* Its complement, which is what a sync ignore list actually needs: everything under the state dir that must NOT
740
+ * come down. Derived from the same predicate rather than listed, so a store added tomorrow is excluded until its
741
+ * class says otherwise — the same default-deny the search floor and the portability classes are built on. */
742
+ export const UNBACKED_STATE_PATHS: readonly string[] = WORKSPACE_STATE_FILES.filter((file) => !BACKED_UP_STATE_PATHS.includes(file.path)).map(
743
+ (file) => file.path,
744
+ );
745
+
611
746
  /* THE ONE WAY AN EXTENSION NAMES ITS SCRATCH HOME — `.intentic/runtime/extensions/<id>`, workspace-relative
612
747
  * and forward-slash so the browser bundle can hold it too; callers join it onto whatever root is in force.
613
748
  *
@@ -616,7 +751,8 @@ export const SEARCHABLE_STATE_PATHS: readonly string[] = WORKSPACE_STATE_FILES.f
616
751
  * classified it. An extension that composes through this helper cannot land outside its own directory, so the
617
752
  * runtime/ entry's `derived` covers whatever it writes tomorrow. Extension ids are validated slugs already;
618
753
  * the replace is defence in depth against a path ever being built from something else. */
619
- export const extensionRuntimeDir = (extension: string): string => `${STATE_DIR}/runtime/extensions/${extension.replaceAll(/[^a-zA-Z0-9._-]/g, "_")}`;
754
+ export const extensionRuntimeDir = (extension: string): string =>
755
+ `${STATE_GROUP_DIR.local}/runtime/extensions/${extension.replaceAll(/[^a-zA-Z0-9._-]/g, "_")}`;
620
756
 
621
757
  /* The manifests whose problems the unreadable-manifest notice SHOWS — the handful a person hand-edits — and the
622
758
  * one fact that decides it is already in the table above.
@@ -651,9 +787,24 @@ export const isReportedManifest = (relPath: string): boolean => REPORTED_MANIFES
651
787
  * quarantine doing half its job. Secret and artifact roots stay until an owner removes them by hand: deleting
652
788
  * content is not the janitor's call, only deleting what the class already says is disposable. */
653
789
  export const RETIRED_WORKSPACE_STATE_DIRS = {
654
- secret: ["claude", "codex", "kimi", "opencode", "cliproxy"],
655
- derived: ["iq", "extensions-runtime", "whisper", "browser/output"],
656
- artifacts: ["attachments", "acceptance", "loops", "workflow-runs", "transcripts"],
790
+ /* The flat spellings, from before the state dir was grouped into its five folders. Every entry in the table
791
+ * used to sit directly under `.intentic/`, and a sandbox that predates the move still has those directories
792
+ * on disk with real contents in them. They are quarantined rather than migrated — the workspace rule is fresh
793
+ * state and no compatibility layers — but quarantine is exactly what they need, because the alternative is
794
+ * worse than leaving them: `auth` at the old spelling is a real credential store that the classifier no
795
+ * longer recognises, so without these names it would read as ordinary workspace content and be indexed by
796
+ * search, carried by an export and copied down by the backup. Being listed here keeps the old copy as
797
+ * untouchable as the new one, and lets the janitor delete the rebuildable half. */
798
+ secret: ["claude", "codex", "kimi", "opencode", "cliproxy", "auth", "ci.json"],
799
+ /* The ownership records at their OLD flat spelling, and a bucket of their own rather than a few more names
800
+ * in `secret` — because the two classes differ on exactly the thing that matters for a leftover. A `secret`
801
+ * travels when the owner opts in at export; an `identity` never travels at all, since a list of who may
802
+ * drive this sandbox arriving in another one is how a source hands itself the target's ownership. Filed under
803
+ * `secret` these would have become carryable by ticking a box, which is the one outcome their live entries
804
+ * are written to prevent. */
805
+ identity: ["owner.json", "members.json", "workspace.json", "control-tokens.json"],
806
+ derived: ["iq", "extensions-runtime", "whisper", "browser/output", "browser", "cache", "runtime", "tmp", ".pnpm-store", "extensions", "verify"],
807
+ artifacts: ["attachments", "acceptance", "loops", "workflow-runs", "transcripts", "artifacts", "sessions"],
657
808
  } as const;
658
809
 
659
810
  /* THE DAEMON'S OWN CONTROL PLANE — the entries directly under the workspace root's `.intentic/` that the file
@@ -668,16 +819,26 @@ export const RETIRED_WORKSPACE_STATE_DIRS = {
668
819
  *
669
820
  * Naming these to the browser gives nothing away that the tree did not already publish — it listed them, sizes
670
821
  * and all. What stays behind the guard is the only thing that ever mattered: the bytes. */
822
+ /* GROUP-RELATIVE NOW, and the one rule the regrouping did NOT simplify — worth saying because every other rule
823
+ * over this tree collapsed to a prefix and this one could not. What the file API refuses to open cuts ACROSS the
824
+ * groups: the capability manifest is `config`, the transcripts are `records`, the browser profiles are `local`,
825
+ * and all of `identity` and `secrets` is in. That is not an accident of the grouping, it is a different question
826
+ * — "would showing the bytes hand someone something" rather than "what kind of thing is this" — so it keeps an
827
+ * explicit list, just one that now names the folder each entry lives in. */
671
828
  const LOCKED_STATE_ENTRIES: ReadonlySet<string> = new Set([
672
- "owner.json",
673
- "members.json",
674
- "capabilities.json",
675
- "ci.json",
829
+ "identity/owner.json",
830
+ "identity/members.json",
831
+ "identity/control-tokens.json",
832
+ "config/capabilities.json",
833
+ "secrets/ci.json",
834
+ "secrets/auth",
835
+ "records/sessions",
836
+ "local/browser",
837
+ /* The provider CLI's own home, which this table does not declare and so has no group to move into — it is
838
+ * written by the agent's runtime rather than by any daemon store. It stays at the state dir's root, and the
839
+ * two-segment match below still reaches it because a bare name joins to itself. Locked for the reason the
840
+ * credential entries are: it holds a live session for whatever the agent is signed into. */
676
841
  "claude.json",
677
- "auth",
678
- "sessions",
679
- "browser",
680
- ...RETIRED_WORKSPACE_STATE_DIRS.secret,
681
842
  ]);
682
843
 
683
844
  /* Whether a workspace-root-relative path lands in that control plane — and so is shown locked rather than
@@ -694,7 +855,18 @@ export const isLockedWorkspacePath = (relPath: string): boolean => {
694
855
  if (segments[0] === ".git") {
695
856
  return true;
696
857
  }
697
- return segments.length >= 2 && segments[0] === STATE_DIR && LOCKED_STATE_ENTRIES.has(segments[1] ?? "");
858
+ if (segments[0] !== STATE_DIR) {
859
+ return false;
860
+ }
861
+ /* Two shapes, because two layouts exist on disk. A current entry is `<group>/<name>`; a leftover from before
862
+ * the regrouping is a bare name directly under the state dir, and the quarantine record is what still knows
863
+ * those are credentials. Both are locked — a stale `auth/` full of real tokens is no safer to open than the
864
+ * live one, and the whole point of quarantining the old spellings was that nothing downstream reclassifies
865
+ * them as ordinary content. */
866
+ return (
867
+ LOCKED_STATE_ENTRIES.has(segments.slice(1, 3).join("/")) ||
868
+ (segments.length >= 2 && RETIRED_WORKSPACE_STATE_DIRS.secret.includes(segments[1] as never))
869
+ );
698
870
  };
699
871
 
700
872
  /* THE LOCKED ENTRIES THE ROOT REPO TRACKS — refused by the file API, and diffable anyway.