@mnstry/atelier 0.2.0-alpha.13 → 0.2.0-alpha.14

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 (58) hide show
  1. package/CHANGELOG.md +233 -0
  2. package/README.md +22 -5
  3. package/docs/blocks/capability-stewardship.md +1 -1
  4. package/docs/blocks/learning-harnesses.md +1 -1
  5. package/docs/capability-stewardship.md +2 -4
  6. package/docs/ingestion.md +3 -3
  7. package/docs/inquiry-harnesses.md +2 -3
  8. package/docs/install.md +3 -3
  9. package/docs/learning-harnesses.md +2 -3
  10. package/docs/local-services.md +42 -3
  11. package/docs/obsidian-contract.md +23 -0
  12. package/docs/obsidian-plugin.md +214 -3
  13. package/docs/obsidian.md +271 -11
  14. package/docs/personal-workspace.md +13 -4
  15. package/docs/release-engineering.md +2 -1
  16. package/docs/repo-boundary-guard.md +26 -3
  17. package/docs/semantic-operation.md +4 -3
  18. package/docs/upgrade.md +174 -2
  19. package/fixtures/templates/local-library.v1.json +2 -1
  20. package/package.json +2 -1
  21. package/plugins/obsidian/main.js +57 -8
  22. package/plugins/obsidian/manifest.json +1 -1
  23. package/src/boundary/content-rules.mjs +532 -67
  24. package/src/boundary/policy.mjs +135 -56
  25. package/src/cli/run.mjs +3 -2
  26. package/src/commands/obsidian.mjs +23 -7
  27. package/src/graph/graph.mjs +13 -5
  28. package/src/graph/knowledge-graph.mjs +19 -1
  29. package/src/ingestion/optional/graphify/graphify_supplier.py +5 -0
  30. package/src/ingestion/optional/graphify/native-closure-pins.json +275 -0
  31. package/src/ingestion/optional/graphify/native-pins.json +306 -0
  32. package/src/knowledge-health/rule-identity.mjs +14 -9
  33. package/src/personal-workspace/index.mjs +1 -1
  34. package/src/personal-workspace/selection.mjs +17 -1
  35. package/src/project/path-match.mjs +2 -1
  36. package/src/projection/obsidian/materialize/prepare-view.mjs +87 -5
  37. package/src/projection/obsidian/personal-workspace.mjs +87 -4
  38. package/src/projection/obsidian/proposals/adapter.mjs +46 -23
  39. package/src/projection/obsidian/proposals/observation.mjs +40 -2
  40. package/src/projection/obsidian/publication/publisher.mjs +72 -5
  41. package/src/projection/project.mjs +3 -2
  42. package/src/runtime/obsidian/engine.mjs +293 -55
  43. package/src/runtime/obsidian/lifecycle.mjs +17 -12
  44. package/src/runtime/obsidian/login-item.mjs +4 -1
  45. package/src/runtime/obsidian/observation.mjs +36 -4
  46. package/src/runtime/obsidian/opening.mjs +3 -2
  47. package/src/runtime/obsidian/personal-selection.mjs +125 -0
  48. package/src/runtime/obsidian/pipeline.mjs +44 -3
  49. package/src/runtime/obsidian/private-lock.mjs +39 -4
  50. package/src/runtime/obsidian/service-main.mjs +58 -12
  51. package/src/runtime/obsidian/service.mjs +66 -6
  52. package/src/runtime/obsidian/view-permission.mjs +334 -0
  53. package/src/ui/control-system.mjs +201 -0
  54. package/src/ui/html-primitives.mjs +3 -0
  55. package/src/upgrade/closure-diagnostics.mjs +135 -0
  56. package/src/upgrade/closure.mjs +649 -0
  57. package/src/upgrade/upgrade.mjs +19 -7
  58. package/templates/external-project-workspace/pack-lifecycle.json +2 -1
@@ -260,10 +260,33 @@ That is how a public site's owner-authorized mock cart stranded real work on a
260
260
  machine for weeks.
261
261
 
262
262
  - `pre-commit` runs `atelier boundary check --staged` — added lines in the
263
- staged diff.
263
+ index Git is committing, including the temporary index of `git commit -a`
264
+ or `git commit <paths>` and a linked worktree's own index.
264
265
  - `pre-push` runs `atelier boundary push-check` — git writes the ref updates to
265
- the hook's stdin, and only that range is judged. A brand-new branch is diffed
266
- against the empty tree, so nothing slips through unscanned.
266
+ the hook's stdin, and only that range is judged: the change from the
267
+ remote's commit to the pushed tip, and every commit the push carries that the
268
+ remote's tracking refs do not hold, each against its parent, so content added
269
+ and removed again within the push is still caught (the finding names its
270
+ commit). A merge is judged only for what is new against all of its parents,
271
+ so a pull merge into the branch you push is not refused for what the remote
272
+ already holds; merging another branch into a feature branch can still be
273
+ refused through the change from the remote's commit, as before. The hook
274
+ passes the remote Git names: a configured remote, or a URL matched to the
275
+ remote configured with it. Only that remote's tracking refs, stale ones
276
+ included, exclude commits (a remote whose `pushurl` names another
277
+ repository is still scoped by its fetch-side tracking refs); a URL no remote
278
+ is configured with excludes only the remote's commit. A hook installed before this judges against every
279
+ remote's tracking refs, so reinstall it (`atelier boundary install-hooks`)
280
+ for history that only another remote holds to be judged on its way to a new
281
+ one. A brand-new branch is diffed against the empty tree, in SHA-1 and SHA-256
282
+ repositories alike, so nothing slips through unscanned. A push carrying more
283
+ than 1000 such commits, or more evidence than the aggregate budget, is
284
+ refused; push it in smaller parts, an earlier commit first. Each diff reads
285
+ what it needs, so a history that swaps large binaries back and forth makes
286
+ the hook slow, within the commit cap and per-diff limits. The remote's
287
+ tracking refs are found by name: a remote whose fetch refspec writes into
288
+ another remote's namespace, or two remote names that differ only in case on
289
+ a case-insensitive file system, can blur which history the remote holds.
267
290
  - `atelier boundary audit` scans the current working tree by default and
268
291
  **reports without blocking**, listing matches, incomplete-read diagnostics,
269
292
  and declared exceptions with their reasons. Use `--head` for a committed
@@ -1,9 +1,10 @@
1
1
  # Semantic operation: internal vanilla profile
2
2
 
3
3
  `src/knowledge/semantic-operation.mjs` connects a host's extraction to the
4
- existing intake and knowledge owners. It is an internal library. There is no
5
- public export, registered schema or CLI command yet. The proposed consumer is
6
- an agent operating the experimental Atelier CLI after Foundation registers it.
4
+ existing intake and knowledge owners. It is an internal library with no public
5
+ export or registered schema. Since 0.2.0-alpha.13 the experimental CLI reaches
6
+ it as `atelier knowledge semantic OPERATION`; an agent operating that command
7
+ is its intended consumer.
7
8
 
8
9
  Atelier never invokes a model. The host supplies its raw output, its normalized
9
10
  candidate envelope, configuration pins and usage report. A deterministic or
package/docs/upgrade.md CHANGED
@@ -9,6 +9,59 @@ workspace between Atelier package releases. The flow is local-only: it does not
9
9
  provision repositories, contact a Git host, mutate the MNSTRY runtime, or write
10
10
  through a browser view.
11
11
 
12
+ ## Upgrading to 0.2.0-alpha.14
13
+
14
+ `atelier upgrade closure --npm-root ABSOLUTE_NPM_ROOT` plans a check of a staged
15
+ candidate's own dependency closure before an upgrade. It reads the candidate's
16
+ lockfile and manifests only and runs no npm command. Exit code 3 is a completed
17
+ plan and exit code 2 a refusal; the command never exits 0, because a plan is not
18
+ proof of an offline reinstall. It refuses `--__proto__` as an unsupported flag.
19
+ See [Consumer dependency closure plan](#consumer-dependency-closure-plan).
20
+
21
+ This release also introduces `@mnstry/atelier/ui`, a tenant-neutral control
22
+ grammar for local Atelier tooling and compatible downstream interfaces. Existing
23
+ consumers do not need to change. Consumers that adopt it map the
24
+ `--system-control-*` custom properties to their own theme.
25
+
26
+ No schema or stored record format changes in this release. The boundary checks
27
+ now judge work they used to miss, so a commit or push that passed on
28
+ 0.2.0-alpha.13 can be refused, and a few other results change:
29
+
30
+ - Reinstall the boundary hooks with `atelier boundary install-hooks`. The new
31
+ pre-push hook passes the remote Git names, so history held only by another
32
+ remote is judged on its way to a new one. The push check judges every commit
33
+ the remote's tracking refs do not hold, each against its parent, binaries
34
+ included. A push carrying more than 1000 such commits or more than the
35
+ evidence budget is refused with `content-scan-incomplete`; push it in
36
+ smaller parts.
37
+ - The pre-commit check reads the index and work tree Git names, so
38
+ `git commit -a`, `git commit <paths>` and commits from a linked worktree are
39
+ judged. It blocks with `pre-commit-repo-unresolved` when no single declared
40
+ repository is being committed, as the push check does.
41
+ - The content scan pins its diff format, so an external diff, a textconv
42
+ driver, changed prefixes or quoted paths no longer leave a change unscanned.
43
+ A staged or pushed deletion of a binary file no longer blocks.
44
+ - The semantic-field review takes only an added review marker as approval; a
45
+ removed marker, or one in a file name, approves nothing. `atelier upgrade`
46
+ applies the same rule.
47
+ - `extract-structure` refuses `parallel: true` with `NATIVE_WORKERS_UNATTESTED`.
48
+ - Knowledge Health regenerates its rule identity for the two graph files: the
49
+ `check` each assessment carries names a new `version`, `sourceTree` and
50
+ `graphBlob`. The rule it applies is unchanged. A workshop contribution
51
+ started on 0.2.0-alpha.13, completed or not, cannot start Review on this
52
+ release (`finding changed; refresh before selecting a new draft`); start
53
+ Review before upgrading, or record a new contribution.
54
+ - `runMaintenanceService` from `@mnstry/atelier/obsidian` refuses a project
55
+ loader that answers with a promise when no `locateProject` is given
56
+ (`service-loader-not-synchronous`); on 0.2.0-alpha.13 such a service
57
+ protected no repository. Pass a synchronous `locateProject`, which answers
58
+ a project naming its folder, to keep an asynchronous loader (see
59
+ [Obsidian](obsidian.md)).
60
+
61
+ A package update supplies no interpretation approval, source-write permission,
62
+ publication authority or runtime activation. Rehearse this exact installed
63
+ version in a disposable workspace before client use.
64
+
12
65
  ## Upgrading to 0.2.0-alpha.13
13
66
 
14
67
  The public [Knowledge Health workshop](knowledge-health/devday/public-default.md)
@@ -250,7 +303,7 @@ For registry installs, pin the exact version and record the resolved version
250
303
  in the lockfile:
251
304
 
252
305
  ```bash
253
- npm install --save-dev @mnstry/atelier@0.2.0-alpha.13
306
+ npm install --save-dev @mnstry/atelier@0.2.0-alpha.14
254
307
  npx mnstry-atelier lock write --project ./atelier.project.json
255
308
  ```
256
309
 
@@ -258,7 +311,7 @@ For Git installs, pin the release tag rather than a branch, so the lock file
258
311
  records exactly what was reviewed:
259
312
 
260
313
  ```bash
261
- npm install --save-dev "git+https://github.com/MNSTRY/atelier.git#v0.2.0-alpha.13"
314
+ npm install --save-dev "git+https://github.com/MNSTRY/atelier.git#v0.2.0-alpha.14"
262
315
  npx mnstry-atelier lock write --project ./atelier.project.json
263
316
  ```
264
317
 
@@ -337,3 +390,122 @@ For the bounded, manually confirmed single-repository workflow, see
337
390
  commands are separate from the legacy flag-based migration workflow above.
338
391
  They preserve history and honor existing commit hooks; they do not install a
339
392
  new package or activate a runtime.
393
+
394
+ ## Consumer dependency closure plan
395
+
396
+ `atelier upgrade closure --npm-root ABSOLUTE_NPM_ROOT` plans a check of a staged
397
+ candidate's own dependency closure. A consumer does not inherit a dependency
398
+ publisher's npm `overrides`, so the candidate's own lockfile is what counts.
399
+
400
+ The command reads these files and no others:
401
+
402
+ - the candidate's `package.json` and `package-lock.json`;
403
+ - the installed `package.json` of every lockfile copy of the selected package;
404
+ - `package.json` in each directory above the npm root, up to the filesystem
405
+ root, to refuse a parent that declares workspaces. When the npm root is under
406
+ your home directory, that includes a `package.json` in the home directory;
407
+ - Atelier's own `package.json`. The CLI reads it on every run for its version;
408
+ this plan also takes the default package name from it when `--package` is
409
+ not given.
410
+
411
+ It does not run npm, use the network, read npm configuration or credentials, or
412
+ write anything. The directory need not be an Atelier workspace, and `--npm-root`
413
+ has no default.
414
+
415
+ Every run prints exactly one JSON document with `"proof": "not-run"`, including
416
+ a run whose arguments are refused. A completed plan holds:
417
+
418
+ - the lockfile's SHA-256, version and entry count;
419
+ - entries by source (`npm`, `git`, `local_path`, `link`, `bundled` and
420
+ `unclassified`, an installed entry with no `resolved`); the counts add up to
421
+ the entry count;
422
+ - the registry hosts the lockfile names, and separately the URL schemes (`http`,
423
+ `https`) it uses;
424
+ - the number of registry tarballs an offline proof would need;
425
+ - lockfile entries whose `os`, `cpu` or `libc` excludes this host;
426
+ - the selected package's lockfile copies, and under `manifests` the path, name,
427
+ version and SHA-256 of each installed manifest that was read;
428
+ - the selected package's exact `overrides` that were checked against every
429
+ installed copy, the ones with no copy, and the ones left unchecked: a
430
+ version-selector key, a nested override, a value that is not an exact
431
+ version, or a package that a link provides (`link-only`, or `linked-copy`
432
+ when a registry copy is installed beside the link);
433
+ - every refusal, with its code, one `reason`, a message and a next step.
434
+
435
+ A refused plan holds its refusals and whatever else was established. A refusal
436
+ of the arguments, the npm root or the shape of the lockfile stops the plan
437
+ there, so it holds nothing about the lockfile's entries; any other refusal is
438
+ listed beside the rest of the classification.
439
+
440
+ Exit code 3 means a completed plan. Exit code 2 means a refusal: the JSON
441
+ document, then `[code] message` with a `Next:` step on standard error. The
442
+ command never exits 0: a plan is not a pass, and this command does not prove an
443
+ offline reinstall.
444
+
445
+ Overrides are taken from the installed manifest only when it is the package the
446
+ lockfile records: each copy's manifest must declare the selected name and the
447
+ version of its own lockfile entry, and all copies must be at one version with
448
+ the same overrides. Otherwise the plan is refused and no override is reported
449
+ as checked.
450
+
451
+ Refusals reuse existing codes:
452
+
453
+ - `usage`: the input is not supported. Reasons: `npm-root-required`,
454
+ `npm-root-not-absolute`, `npm-root-not-directory`, `npm-root-linked` (the
455
+ path is a link or passes through one; the refusal prints the directory's
456
+ `realPath` to pass instead), `argument-unsupported` (an unknown flag, an
457
+ extra argument, a flag with no value, an empty `--package` or
458
+ `--registry-host`, or a flag given more than once; an empty value after
459
+ `--npm-root=` is `npm-root-required`), `manifest-missing`, `lockfile-missing`,
460
+ `lockfile-invalid` (not a JSON object, or a `packages` entry that is not an
461
+ object), `lockfile-version` (version 1, or no `packages` map), `workspaces`,
462
+ `prefix-mismatch` (a parent directory declares workspaces) and
463
+ `linked-node-modules`.
464
+ - `consumer-closure-incomplete`: an offline reinstall could not be proven for a
465
+ lockfile entry. Reasons: `missing-integrity`, `git-source`, `other-host`,
466
+ `outside-project`, `link-target-missing` (a link that names no folder),
467
+ `zero-copies`, `selected-package-unreadable`, `selected-package-mismatch`
468
+ (an installed manifest that is not the package its lockfile entry records)
469
+ and `selected-copies-differ`.
470
+ - `override-not-inherited`: reason `copy-off-pin`. A copy is installed at a
471
+ version other than the selected package's exact pin. More than one copy, all
472
+ at the pin, is reported and not refused.
473
+
474
+ Options:
475
+
476
+ - `--package NAME` selects the package whose overrides are checked. It defaults
477
+ to `@mnstry/atelier`.
478
+ - `--registry-host HOST[,HOST]` names the approved registry hosts. A tarball on
479
+ any other host, or at a URL with no readable host, is then refused. Without
480
+ it, hosts are reported and `approvedRegistryVerified` is `false`, because the
481
+ plan does not read npm configuration.
482
+
483
+ Give each option once, with a value, after the word `closure`. An option with
484
+ an empty value is refused; leave the option out to use its default.
485
+
486
+ Limits:
487
+
488
+ - The plan cannot run `npm prefix`, so `prefixVerified` is always `false`.
489
+ Instead it requires `--npm-root` to be the directory's real path and refuses
490
+ any parent directory of it that declares workspaces, whether or not that
491
+ parent's workspaces include the candidate. A path that is a link, or passes
492
+ through one, is refused, with its `realPath` in the JSON result. On macOS,
493
+ `/tmp` is a link into `/private` and `$TMPDIR` passes through `/var`, another
494
+ such link, so a candidate staged there needs its real path (for example
495
+ `pwd -P` from inside it).
496
+ - Containment of links and local files is checked by path, without following
497
+ links. The folder a link points to is not opened: an override for a package
498
+ that a link provides is listed as unchecked, never as checked.
499
+ - Approving a host does not verify the transport. A host is matched by its
500
+ exact name and port, whatever the scheme; `registrySchemes` counts `http`
501
+ and `https` separately.
502
+ - The manifest binding compares the name and version the installed manifest
503
+ declares. It does not verify the installed files against the lockfile's
504
+ `integrity`.
505
+ - Platform evidence uses this process's `os` and `cpu`; `libc` is unknown
506
+ unless a caller of `planConsumerClosure` supplies it.
507
+ - A flag with no value placed before the word `closure` (for example
508
+ `atelier upgrade --package closure`) takes `closure` as its value, so the
509
+ ordinary upgrade command runs instead of this one.
510
+ - `lockTreeDifferences`, the comparison of two `npm ls` trees, is in the source
511
+ but is not exported from the package and no command uses it.
@@ -67,7 +67,8 @@
67
67
  "0.2.0-alpha.9",
68
68
  "0.2.0-alpha.10",
69
69
  "0.2.0-alpha.12",
70
- "0.2.0-alpha.13"
70
+ "0.2.0-alpha.13",
71
+ "0.2.0-alpha.14"
71
72
  ],
72
73
  "packRefs": []
73
74
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnstry/atelier",
3
- "version": "0.2.0-alpha.13",
3
+ "version": "0.2.0-alpha.14",
4
4
  "private": false,
5
5
  "description": "Local toolkit that turns a Git repository into an ontology-governed knowledge graph, enforcement layer, and runtime for people, agents, and tools. No telemetry or required hosted account; optional host-bound vault adapters.",
6
6
  "keywords": [
@@ -61,6 +61,7 @@
61
61
  "./readiness": "./src/readiness/readiness.mjs",
62
62
  "./readiness-protocols": "./src/readiness-protocols/bundled-pack.mjs",
63
63
  "./server": "./src/server/local-sidecar.mjs",
64
+ "./ui": "./src/ui/control-system.mjs",
64
65
  "./harness": "./src/harness/context.mjs",
65
66
  "./collaboration": "./src/collaboration/index.mjs",
66
67
  "./support": "./src/support/support-bundle.mjs",
@@ -196,11 +196,14 @@ class AtelierStatusModal extends obsidian.Modal {
196
196
  class AtelierProjectionPlugin extends obsidian.Plugin {
197
197
  async onload() {
198
198
  this.channel = null
199
+ this.lastObservedStatus = null
199
200
  this.session = null
200
201
  this.cycling = null
201
202
  this.unloaded = false
203
+ this.channelAdmitted = false
202
204
  this.view = { state: 'connecting', reason: 'not-yet-asked', report: null }
203
205
  this.shownState = null
206
+ this.noticeState = null
204
207
  // A round's failure that differs from the failure shown, not yet seen twice in a row.
205
208
  this.pendingFailure = null
206
209
  this.appVersion = typeof obsidian.apiVersion === 'string' ? obsidian.apiVersion : null
@@ -226,6 +229,7 @@ class AtelierProjectionPlugin extends obsidian.Plugin {
226
229
  this.setView({ state: 'not-set-up', reason: 'node-modules-unavailable', report: null })
227
230
  return
228
231
  }
232
+ this.channelAdmitted = true
229
233
  // This launch of the plugin: the service tells two apps holding the same vault apart by it.
230
234
  this.instanceId = `pi-${this.crypto.randomBytes(16).toString('hex')}`
231
235
  await this.readChannel()
@@ -238,6 +242,8 @@ class AtelierProjectionPlugin extends obsidian.Plugin {
238
242
  // The lease lapses by itself; releasing it says at once that the vault closed. A round still under way stops at its
239
243
  // next step, and a session it opens after this is released at once.
240
244
  this.unloaded = true
245
+ this.channelAdmitted = false
246
+ this.lastObservedStatus = null
241
247
  const session = this.session
242
248
  this.session = null
243
249
  if (this.channel && session) void this.release(this.channel, session)
@@ -245,6 +251,7 @@ class AtelierProjectionPlugin extends obsidian.Plugin {
245
251
 
246
252
  // Obsidian calls this when data.json changed on disk: Atelier republished the channel.
247
253
  async onExternalSettingsChange() {
254
+ if (!this.channelAdmitted || this.unloaded) return
248
255
  await this.readChannel()
249
256
  // A round under way still speaks for the old channel; the next one uses the new.
250
257
  if (this.cycling) await this.cycling
@@ -252,13 +259,19 @@ class AtelierProjectionPlugin extends obsidian.Plugin {
252
259
  }
253
260
 
254
261
  async readChannel() {
262
+ if (!this.channelAdmitted || this.unloaded) return
255
263
  let data = null
256
264
  try { data = await this.loadData() } catch { data = null }
265
+ if (!this.channelAdmitted || this.unloaded) return
257
266
  const next = channelOf(data)
258
267
  const same = next && this.channel && next.host === this.channel.host && next.port === this.channel.port && next.scopeId === this.channel.scopeId && next.bearer === this.channel.bearer
259
- if (!same) this.session = null
268
+ if (!same) {
269
+ this.session = null
270
+ this.lastObservedStatus = null
271
+ } else if (this.lastObservedStatus?.channel === this.channel) this.lastObservedStatus.channel = next
260
272
  this.channel = next
261
273
  if (next === null) this.setView({ state: 'not-set-up', reason: data === null ? 'no-channel-data' : 'channel-data-unusable', report: null })
274
+ else if (!same) this.setView({ state: 'connecting', reason: 'not-yet-asked', report: null })
262
275
  }
263
276
 
264
277
  // One round: shake hands (once per session), renew the lease, read the status.
@@ -270,12 +283,12 @@ class AtelierProjectionPlugin extends obsidian.Plugin {
270
283
 
271
284
  // Whether a round that started for `channel` may still act.
272
285
  current(channel) {
273
- return !this.unloaded && channel === this.channel
286
+ return this.channelAdmitted && !this.unloaded && channel === this.channel
274
287
  }
275
288
 
276
289
  async runCycle() {
277
290
  const channel = this.channel
278
- if (channel === null || this.unloaded) return
291
+ if (!this.channelAdmitted || channel === null || this.unloaded) return
279
292
  if (this.session === null) {
280
293
  if (!(await this.handshake(channel))) return
281
294
  } else {
@@ -290,6 +303,7 @@ class AtelierProjectionPlugin extends obsidian.Plugin {
290
303
  return
291
304
  }
292
305
  // The service started again since, the vault's key changed, or the session is not accepted: shake hands at once.
306
+ this.lastObservedStatus = null
293
307
  if (!(await this.handshake(channel))) return
294
308
  }
295
309
  }
@@ -386,13 +400,18 @@ class AtelierProjectionPlugin extends obsidian.Plugin {
386
400
 
387
401
  unreachable(answer) {
388
402
  const reason = answer.kind === 'unreachable' ? answer.code : typeof answer.error === 'string' ? answer.error : answer.body && typeof answer.body.error === 'string' ? answer.body.error : `answered-${answer.statusCode}`
389
- return { state: 'unreachable', reason, report: null }
403
+ // Retain history only for a transport interruption, never an unsealed or
404
+ // refused answer, failed listener proof or unknown authentication state.
405
+ const transportFailure = answer.kind === 'unreachable' && ['refused', 'timeout'].includes(answer.code)
406
+ return { state: 'unreachable', reason, report: null, transportFailure }
390
407
  }
391
408
 
392
409
  // What a round found. A failure other than the one shown ('not set up' where 'service unreachable' is shown, or the other
393
410
  // way round) is shown only when the next round finds it too, so answers that alternate between them do not make the
394
411
  // status bar flip. Anything else is shown at once.
395
412
  showRound(view) {
413
+ // A setup refusal clears remembered evidence even while its label is debounced.
414
+ if (view.state === 'not-set-up' || (view.state === 'unreachable' && view.transportFailure !== true)) this.lastObservedStatus = null
396
415
  if (ROUND_FAILURES.has(view.state) && ROUND_FAILURES.has(this.shownState) && view.state !== this.shownState && this.pendingFailure !== view.state) {
397
416
  this.pendingFailure = view.state
398
417
  return
@@ -404,11 +423,32 @@ class AtelierProjectionPlugin extends obsidian.Plugin {
404
423
  // Nothing is shown once the plugin unloaded.
405
424
  if (this.unloaded) return
406
425
  this.pendingFailure = null
407
- const previous = this.shownState
426
+ const previous = this.noticeState
427
+ // A transient display projection, never a freshness or permission record.
428
+ // Only a report of this exact channel may be remembered. `channel` is an
429
+ // in-memory identity binding, never shown or stored; the other fields hold
430
+ // no service health, edit counts, source content or credentials.
431
+ if (!this.channel || view.state === 'not-set-up' || view.state === 'unsupported' || (view.state === 'unreachable' && view.transportFailure !== true)) this.lastObservedStatus = null
432
+ else if (view.report !== null) {
433
+ const report = view.report
434
+ const observed = isPlainObject(report) && report.schema === CHANNEL.statusSchema && report.scopeId === this.channel.scopeId && isPlainObject(report.view) && report.view.state !== 'disabled' ? report.view : null
435
+ this.lastObservedStatus = observed === null ? null : {
436
+ channel: this.channel, state: view.state, reason: view.reason,
437
+ generationId: typeof observed.generationId === 'string' ? observed.generationId : null,
438
+ checkedAt: typeof observed.checkedAt === 'string' ? observed.checkedAt : null,
439
+ }
440
+ }
408
441
  this.view = view
409
442
  this.statusBarEl.setText(labelOf(view))
410
443
  this.statusBarEl.setAttr('aria-label', `${labelOf(view)} (${readable(view.reason)})`)
411
444
  this.shownState = view.state
445
+ // A republished channel resets the display before its first answer, but
446
+ // does not repeat attention notices or erase the recovery transition.
447
+ if (view.state === 'connecting') {
448
+ if (previous === null) this.noticeState = view.state
449
+ return
450
+ }
451
+ this.noticeState = view.state
412
452
  if (previous === view.state || previous === null) return
413
453
  if (Object.prototype.hasOwnProperty.call(ATTENTION, view.state)) new obsidian.Notice(ATTENTION[view.state](view))
414
454
  else if (view.state === 'current' && Object.prototype.hasOwnProperty.call(ATTENTION, previous)) new obsidian.Notice('Atelier: this view is current again.')
@@ -419,13 +459,22 @@ class AtelierProjectionPlugin extends obsidian.Plugin {
419
459
  const view = isPlainObject(report.view) ? report.view : {}
420
460
  const count = (value) => (Number.isInteger(value) ? String(value) : 'unknown')
421
461
  const channel = this.channel
462
+ const last = this.lastObservedStatus?.channel === channel ? this.lastObservedStatus : null
463
+ const missingGeneration = this.view.state === 'unreachable' ? 'unknown' : 'none yet'
464
+ const missingCheck = this.view.state === 'unreachable' ? 'unknown' : 'not yet'
422
465
  return [
423
466
  ['View', channel ? channel.scopeId : 'not set up'],
424
467
  ['State', labelOf(this.view).replace(/^Atelier: /, '')],
425
468
  ['Reason', readable(this.view.reason)],
426
- ['Generation', typeof view.generationId === 'string' ? view.generationId : 'none yet'],
427
- ['Prepared generation', typeof view.preparedGenerationId === 'string' ? view.preparedGenerationId : 'none yet'],
428
- ['Checked at', typeof view.checkedAt === 'string' ? view.checkedAt : 'not yet'],
469
+ ...(this.view.state !== 'unreachable' ? [] : last ? [
470
+ ['Last observed state', readable(last.state)],
471
+ ['Last observed reason', readable(last.reason)],
472
+ ['Last observed generation', last.generationId || 'none yet'],
473
+ ['Last observed check', last.checkedAt || 'not yet'],
474
+ ] : [['Last observed status', 'not available']]),
475
+ ['Generation', typeof view.generationId === 'string' ? view.generationId : missingGeneration],
476
+ ['Prepared generation', typeof view.preparedGenerationId === 'string' ? view.preparedGenerationId : missingGeneration],
477
+ ['Checked at', typeof view.checkedAt === 'string' ? view.checkedAt : missingCheck],
429
478
  ['Held edits', count(view.heldNoteCount)],
430
479
  ['Retained edits', count(view.retainedEdits)],
431
480
  ['Pending edits', count(isPlainObject(report.pendingEdits) ? report.pendingEdits.open : null)],
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "atelier-projection",
3
3
  "name": "Atelier",
4
- "version": "1.1.1",
4
+ "version": "1.1.2",
5
5
  "minAppVersion": "1.13.7",
6
6
  "description": "Shows whether this Atelier view is current and tells Atelier's maintenance service that the vault is open. Shipped by Atelier into the vaults it manages.",
7
7
  "author": "MNSTRY",