@mnstry/atelier 0.2.0-alpha.11 → 0.2.0-alpha.12

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 (81) hide show
  1. package/CHANGELOG.md +344 -0
  2. package/README.md +10 -6
  3. package/contracts/atelier-obsidian-generation-manifest.v2.schema.json +372 -0
  4. package/contracts/compat-baseline.json +1 -1
  5. package/docs/assurance-controls.md +3 -2
  6. package/docs/install.md +3 -3
  7. package/docs/local-services.md +62 -11
  8. package/docs/obsidian-contract.md +669 -35
  9. package/docs/obsidian-plugin.md +585 -0
  10. package/docs/obsidian.md +233 -50
  11. package/docs/release-engineering.md +7 -1
  12. package/docs/upgrade.md +23 -3
  13. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/case-only-collision.v2.json +160 -0
  14. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/flat-note-path.v2.json +160 -0
  15. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/hidden-folder.v2.json +160 -0
  16. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/identity-in-the-body.v2.json +160 -0
  17. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/link-breaking-name.v2.json +160 -0
  18. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/missing-identity-region.v2.json +156 -0
  19. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/missing-layout-version.v2.json +159 -0
  20. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/note-named-like-a-folder.v2.json +160 -0
  21. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/unknown-region-kind.v2.json +160 -0
  22. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/v1-contract-version.v2.json +161 -0
  23. package/fixtures/obsidian/contracts/generation-manifest-v2/valid/three-notes.v2.json +160 -0
  24. package/fixtures/obsidian/edits/workspace.json +1 -1
  25. package/fixtures/obsidian/materialization/expected-full.json +21 -21
  26. package/fixtures/obsidian/materialization/expected-full.layout-1.json +99 -0
  27. package/fixtures/obsidian/materialization/expected-scoped.json +7 -7
  28. package/fixtures/obsidian/materialization/expected-scoped.layout-1.json +39 -0
  29. package/package.json +7 -3
  30. package/plugins/obsidian/main.js +444 -0
  31. package/plugins/obsidian/manifest.json +9 -0
  32. package/plugins/obsidian/styles.css +22 -0
  33. package/src/commands/obsidian.mjs +89 -10
  34. package/src/contracts/corpus.mjs +6 -0
  35. package/src/egress/forbidden-egress.mjs +2 -0
  36. package/src/project/private-state.mjs +10 -1
  37. package/src/projection/obsidian/contracts.mjs +224 -18
  38. package/src/projection/obsidian/edits/apply.mjs +12 -7
  39. package/src/projection/obsidian/edits/regions.mjs +137 -42
  40. package/src/projection/obsidian/materialize/index.mjs +6 -3
  41. package/src/projection/obsidian/materialize/path-registry.mjs +443 -57
  42. package/src/projection/obsidian/materialize/prepare-view.mjs +382 -131
  43. package/src/projection/obsidian/materialize/redaction.mjs +327 -0
  44. package/src/projection/obsidian/materialize/settings.mjs +113 -34
  45. package/src/projection/obsidian/plugin-bridge/bundle.mjs +50 -0
  46. package/src/projection/obsidian/plugin-bridge/channel.mjs +174 -0
  47. package/src/projection/obsidian/plugin-bridge/index.mjs +10 -0
  48. package/src/projection/obsidian/proposals/observation.mjs +10 -5
  49. package/src/projection/obsidian/publication/bridge-script.mjs +11 -5
  50. package/src/projection/obsidian/publication/index.mjs +2 -1
  51. package/src/projection/obsidian/publication/publisher.mjs +173 -26
  52. package/src/projection/obsidian/publication/transport.mjs +63 -8
  53. package/src/projection/obsidian/publication/vault-list.mjs +199 -0
  54. package/src/projection/obsidian/recovery/restart.mjs +3 -1
  55. package/src/projection/obsidian/recovery/store.mjs +11 -4
  56. package/src/projection/obsidian/selection-ui/focus.mjs +14 -7
  57. package/src/projection/obsidian/selection-ui/selection-state.mjs +2 -2
  58. package/src/projection/obsidian/selection-ui/selection.mjs +8 -3
  59. package/src/runtime/obsidian/app-capability.mjs +104 -12
  60. package/src/runtime/obsidian/app-production-seams.mjs +126 -39
  61. package/src/runtime/obsidian/app-registration.mjs +166 -0
  62. package/src/runtime/obsidian/engine.mjs +127 -11
  63. package/src/runtime/obsidian/extension-points.mjs +1 -1
  64. package/src/runtime/obsidian/index.mjs +5 -1
  65. package/src/runtime/obsidian/launch-plan.mjs +88 -0
  66. package/src/runtime/obsidian/lifecycle.mjs +105 -14
  67. package/src/runtime/obsidian/opening.mjs +227 -55
  68. package/src/runtime/obsidian/pending-edits.mjs +10 -0
  69. package/src/runtime/obsidian/plugin-channel.mjs +281 -0
  70. package/src/runtime/obsidian/plugin-choice.mjs +170 -0
  71. package/src/runtime/obsidian/plugin-drift.mjs +76 -0
  72. package/src/runtime/obsidian/plugin-presence.mjs +48 -0
  73. package/src/runtime/obsidian/service-main.mjs +18 -5
  74. package/src/runtime/obsidian/service-record.mjs +36 -0
  75. package/src/runtime/obsidian/service-server.mjs +50 -12
  76. package/src/runtime/obsidian/service.mjs +100 -6
  77. package/src/runtime/obsidian/state-store.mjs +13 -1
  78. package/src/server/local-sidecar.mjs +24 -5
  79. package/src/server/server.mjs +18 -5
  80. package/src/upgrade/upgrade.mjs +1 -1
  81. package/templates/external-project-workspace/pack-lifecycle.json +1 -1
@@ -171,6 +171,12 @@ export const CONTRACT_CORPUS = [
171
171
  name: `atelier-obsidian-${shape}`, contractFile: `contracts/atelier-obsidian-${shape}.v1.schema.json`,
172
172
  fixtureRoot: `fixtures/obsidian/contracts/${shape}`, registry: false,
173
173
  })),
174
+ // Vault layout 2's manifest: a new major, beside v1, which stays unchanged
175
+ // and still validates every generation an earlier release wrote.
176
+ {
177
+ name: 'atelier-obsidian-generation-manifest-v2', contractFile: 'contracts/atelier-obsidian-generation-manifest.v2.schema.json',
178
+ fixtureRoot: 'fixtures/obsidian/contracts/generation-manifest-v2', registry: false,
179
+ },
174
180
  {
175
181
  name: 'analysis-adapter-claim-output',
176
182
  contractFile: 'contracts/analysis-adapter.v1.schema.json',
@@ -17,6 +17,8 @@ export const DEFAULT_EGRESS_SCAN_PATHS = [
17
17
  'templates',
18
18
  'examples',
19
19
  'skills',
20
+ // Atelier's own Obsidian plugin: shipped, and run inside the app of every vault Atelier manages.
21
+ 'plugins',
20
22
  ]
21
23
 
22
24
  const SCRIPT_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.sh'])
@@ -62,7 +62,7 @@ export function openRegularFileNoFollow(file, flags = fs.constants.O_RDONLY, mod
62
62
  // leaf before open and bind the opened descriptor back to that same file
63
63
  // identity where the filesystem supplies stable device/inode values.
64
64
  if (before && before.ino !== 0 && (before.dev !== opened.dev || before.ino !== opened.ino)) {
65
- throw new Error('state leaf changed while opening')
65
+ throw Object.assign(new Error('state leaf changed while opening'), { code: 'ELEAFCHANGED' })
66
66
  }
67
67
  return descriptor
68
68
  } catch (error) {
@@ -80,6 +80,15 @@ export function readRegularTextNoFollow(file) {
80
80
  }
81
81
  }
82
82
 
83
+ // The real path of a target as the file system stores it: on a volume that
84
+ // folds letter case (macOS by default) each component in the case it is stored
85
+ // in, which is also how the operating system reports a working directory, while
86
+ // Node's own resolution keeps the case it was given. On Windows Node's own
87
+ // resolution is kept: the native one also expands short 8.3 names.
88
+ export function realPathAsStored(target) {
89
+ return process.platform === 'win32' ? fs.realpathSync(target) : fs.realpathSync.native(target)
90
+ }
91
+
83
92
  export function syncPrivateDirectory(directory) {
84
93
  let fd;
85
94
  try { fd = fs.openSync(directory, fs.constants.O_RDONLY); fs.fsyncSync(fd); }
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs'
2
2
  import { createHash } from 'node:crypto'
3
3
  import { validateJsonSchema } from '../../export/atelier-export-contract.mjs'
4
+ import { titleCase } from '../../graph/knowledge-graph.mjs'
4
5
 
5
6
  // Obsidian projection contracts: closed v1 document shapes, the checks a JSON
6
7
  // schema cannot express, and the scope selector algebra.
@@ -29,12 +30,15 @@ const MAX_SELECTOR_DEPTH = 64
29
30
 
30
31
  // Portable shapes may be committed or shared and must never carry a
31
32
  // machine-local absolute path, port or policy. Private shapes live only in
32
- // ignored machine-private state.
33
+ // ignored machine-private state. A shape registered in more than one major
34
+ // version is validated against the version its `schema` names; the generation
35
+ // manifest has two, one per vault layout.
33
36
  export const OBSIDIAN_CONTRACTS = Object.freeze([
34
37
  ['corpus-profile', 'portable'],
35
38
  ['scope', 'portable'],
36
39
  ['source-snapshot', 'portable'],
37
40
  ['generation-manifest', 'portable'],
41
+ ['generation-manifest', 'portable', 2],
38
42
  ['publication-journal', 'private'],
39
43
  ['service-state', 'private'],
40
44
  ['edit-operation', 'portable'],
@@ -42,13 +46,14 @@ export const OBSIDIAN_CONTRACTS = Object.freeze([
42
46
  ['proposal-receipt', 'portable'],
43
47
  ['acceptance-receipt', 'portable'],
44
48
  ['ext-settings', 'portable'],
45
- ].map(([shape, portability]) => Object.freeze({
49
+ ].map(([shape, portability, version = 1]) => Object.freeze({
46
50
  shape,
47
51
  portability,
48
- name: `atelier-obsidian-${shape}`,
49
- schemaConst: `atelier-obsidian-${shape}/v1`,
50
- contractFile: `contracts/atelier-obsidian-${shape}.v1.schema.json`,
51
- fixtureRoot: `fixtures/obsidian/contracts/${shape}`,
52
+ version,
53
+ name: version === 1 ? `atelier-obsidian-${shape}` : `atelier-obsidian-${shape}-v${version}`,
54
+ schemaConst: `atelier-obsidian-${shape}/v${version}`,
55
+ contractFile: `contracts/atelier-obsidian-${shape}.v${version}.schema.json`,
56
+ fixtureRoot: version === 1 ? `fixtures/obsidian/contracts/${shape}` : `fixtures/obsidian/contracts/${shape}-v${version}`,
52
57
  })))
53
58
 
54
59
  export class ObsidianContractRefusal extends Error {
@@ -80,18 +85,33 @@ function compareIds(left, right) {
80
85
 
81
86
  const schemaCache = new Map()
82
87
 
83
- function contractFor(shape) {
84
- const contract = OBSIDIAN_CONTRACTS.find((item) => item.shape === shape)
85
- if (!contract) refuse('unknown-contract-shape', 'no Obsidian contract is registered for the requested shape')
86
- return contract
88
+ // The contract a document is validated against: the registered version its
89
+ // `schema` names, else the first version of the shape, whose refusal then
90
+ // names the mismatch.
91
+ function contractFor(shape, doc) {
92
+ const versions = OBSIDIAN_CONTRACTS.filter((item) => item.shape === shape)
93
+ if (versions.length === 0) refuse('unknown-contract-shape', 'no Obsidian contract is registered for the requested shape')
94
+ return versions.find((item) => isPlainObject(doc) && doc.schema === item.schemaConst) ?? versions[0]
87
95
  }
88
96
 
89
97
  function loadSchema(contract) {
90
- if (!schemaCache.has(contract.shape)) {
98
+ if (!schemaCache.has(contract.name)) {
91
99
  const url = new URL(`../../../${contract.contractFile}`, import.meta.url)
92
- schemaCache.set(contract.shape, JSON.parse(fs.readFileSync(url, 'utf8')))
100
+ schemaCache.set(contract.name, JSON.parse(fs.readFileSync(url, 'utf8')))
93
101
  }
94
- return schemaCache.get(contract.shape)
102
+ return schemaCache.get(contract.name)
103
+ }
104
+
105
+ // Whether a value is an identifier as the contracts define one: the
106
+ // `identifier` of the scope contract, which names a view, read from the
107
+ // shipped contract rather than restated.
108
+ let identifierRule = null
109
+ export function isContractIdentifier(value) {
110
+ if (identifierRule === null) {
111
+ const { minLength, maxLength, pattern } = loadSchema(contractFor('scope')).$defs.identifier
112
+ identifierRule = { minLength, maxLength, pattern: new RegExp(pattern, 'u') }
113
+ }
114
+ return typeof value === 'string' && value.length >= identifierRule.minLength && value.length <= identifierRule.maxLength && identifierRule.pattern.test(value)
95
115
  }
96
116
 
97
117
  const ABSOLUTE_PATH = /^(?:\/|~[\\/]|[A-Za-z]:[\\/]|\\\\|file:)/
@@ -172,12 +192,30 @@ const SEMANTIC_CHECKS = {
172
192
  }
173
193
  })
174
194
  },
175
- 'generation-manifest'(doc, errors) {
195
+ 'generation-manifest'(doc, errors, version) {
176
196
  const identities = doc.notes.map((note) => `${note.repoId}\u0000${note.nodeId}`)
177
197
  if (duplicates(identities).length > 0) errors.push({ code: 'duplicate-identity', message: '/notes repeats a canonical identity' })
178
198
  if (duplicates(doc.notes.map((note) => note.path.toLowerCase())).length > 0) {
179
199
  errors.push({ code: 'path-collision', message: '/notes allocates one path to more than one note' })
180
200
  }
201
+ if (version === 2) {
202
+ // Notes and files share folders in layout 2: no two of them may be one file on a case- or
203
+ // normalization-insensitive file system, and no file may have the name of a folder.
204
+ const files = [...doc.notes.map((note) => note.path), ...doc.attachments.map((attachment) => attachment.path)]
205
+ if (duplicates(files.map(collisionKey)).length > 0) errors.push({ code: 'path-collision', message: '/notes and /attachments allocate one path to more than one file' })
206
+ const folders = new Set(files.flatMap((file) => file.split('/').slice(0, -1).map((_, index, parts) => collisionKey(parts.slice(0, index + 1).join('/')))))
207
+ if (files.some((file) => folders.has(collisionKey(file)))) errors.push({ code: 'path-collision', message: '/notes and /attachments give a file the name of a folder' })
208
+ doc.notes.forEach((note, index) => {
209
+ const { identity, body, generated } = note.regions
210
+ const last = generated.at(-1)
211
+ const inPrefix = identity.end <= body.start
212
+ const atEnd = last?.kind === 'identity' && last.range.start === identity.start && last.range.end === identity.end
213
+ if (!inPrefix && !atEnd) errors.push({ code: 'identity-region-misplaced', message: `/notes/${index}/regions/identity is neither in the front matter nor the last generated region` })
214
+ if (generated.some((region, at) => region.kind === 'identity' && at !== generated.length - 1)) {
215
+ errors.push({ code: 'identity-region-misplaced', message: `/notes/${index}/regions/generated has an identity region that is not the last` })
216
+ }
217
+ })
218
+ }
181
219
  if (duplicates(doc.links.map((link) => link.edgeId)).length > 0) errors.push({ code: 'duplicate-identity', message: '/links repeats an edge identity' })
182
220
  const nodeIds = new Set(doc.notes.map((note) => note.nodeId))
183
221
  doc.links.forEach((link, index) => {
@@ -187,7 +225,7 @@ const SEMANTIC_CHECKS = {
187
225
  }
188
226
  })
189
227
  doc.notes.forEach((note, index) => {
190
- const ranges = [note.regions.frontmatter, note.regions.body, ...note.regions.generated.map((region) => region.range)].filter(Boolean)
228
+ const ranges = [note.regions.frontmatter, note.regions.identity, note.regions.body, ...note.regions.generated.map((region) => region.range)].filter(Boolean)
191
229
  if (ranges.some((range) => range.end < range.start)) errors.push({ code: 'invalid-byte-range', message: `/notes/${index}/regions has a range that ends before it starts` })
192
230
  })
193
231
  if (doc.completeness.status === 'complete' && doc.completeness.writtenNotes !== doc.completeness.expectedNotes) {
@@ -227,11 +265,11 @@ const SEMANTIC_CHECKS = {
227
265
  // { layer: 'schema' | 'semantic', code, message }. Semantic checks run only on
228
266
  // schema-valid documents, so they can rely on the shape.
229
267
  export function validateObsidianContract(shape, doc) {
230
- const contract = contractFor(shape)
268
+ const contract = contractFor(shape, doc)
231
269
  const schemaErrors = validateJsonSchema(loadSchema(contract), doc)
232
270
  if (schemaErrors.length > 0) return schemaErrors.map((message) => ({ layer: 'schema', code: 'schema-violation', message }))
233
271
  const errors = []
234
- SEMANTIC_CHECKS[shape](doc, errors)
272
+ SEMANTIC_CHECKS[shape](doc, errors, contract.version)
235
273
  if (contract.portability === 'portable') {
236
274
  const found = []
237
275
  collectAbsolutePaths(doc, '', found)
@@ -260,9 +298,12 @@ export function readObsidianExtSettings(projectConfigDoc) {
260
298
  }
261
299
 
262
300
  // ---------------------------------------------------------------------------
263
- // Note identity and readable paths
301
+ // Note identity and readable paths, vault layout 1
264
302
  // ---------------------------------------------------------------------------
265
303
 
304
+ // Layout 1, what releases up to 0.2.0-alpha.11 wrote and what a view held for
305
+ // an open edit is still prepared in: `notes/<readable title>--<suffix>.md`.
306
+
266
307
  const RESERVED_BASENAMES = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/i
267
308
 
268
309
  // Stable identity suffix: a digest of repository identity and node identity,
@@ -313,6 +354,171 @@ export function allocateNotePaths(nodes) {
313
354
  return allocated
314
355
  }
315
356
 
357
+ // ---------------------------------------------------------------------------
358
+ // Vault layout 2: readable names
359
+ // ---------------------------------------------------------------------------
360
+
361
+ // The file name is the title a person reads and the folders are the
362
+ // repository's own. These are the pure naming rules; allocation, collisions
363
+ // and stability are the path registry's (materialize/path-registry.mjs).
364
+
365
+ export const VAULT_LAYOUT_VERSION = 2
366
+ export const NAME_BYTES = 150
367
+ export const FOLDER_NAME_BYTES = 120
368
+ export const QUALIFIER_STEM_BYTES = 60
369
+ export const MIN_NAME_BYTES = 16
370
+
371
+ // Windows device names, compared on the part before the first dot.
372
+ const WINDOWS_DEVICE_NAMES = /^(?:con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³]|conin\$|conout\$)$/i
373
+ // Characters no name may hold: controls, path separators, what Windows
374
+ // forbids, and what ends or splits an Obsidian link (`# ^ [ ] |`).
375
+ const NAME_FORBIDDEN = /[\u0000-\u001f\u007f-\u009f\u2028\u2029/\\:*?"<>|#^[\]]/g
376
+ // Invisible formatting that can make a name read as another: bidirectional
377
+ // embedding, override and isolate controls, and a stray U+FEFF.
378
+ const NAME_INVISIBLE = /[\u202a-\u202e\u2066-\u2069\ufeff]/g
379
+ // What a cut may leave dangling at the end of a name.
380
+ const NAME_DANGLING = /[\p{M}\u200d\ufe00-\ufe0f]+$/u
381
+
382
+ const trimName = (value) => value.replace(/^[ .]+|[ .]+$/g, '')
383
+
384
+ // Two paths that a case-insensitive or normalization-insensitive file system
385
+ // would treat as one share a collision key.
386
+ export function collisionKey(value) {
387
+ return value.normalize('NFKC').toUpperCase().toLowerCase().normalize('NFKC')
388
+ }
389
+
390
+ // The longest prefix of `value` within `maxBytes` of UTF-8, cut between code
391
+ // points, without a combining character, joiner or variation selector left
392
+ // dangling at the end.
393
+ export function cutName(value, maxBytes) {
394
+ if (Buffer.byteLength(value, 'utf8') <= maxBytes) return value
395
+ let kept = ''
396
+ let bytes = 0
397
+ for (const character of value) {
398
+ bytes += Buffer.byteLength(character, 'utf8')
399
+ if (bytes > maxBytes) break
400
+ kept += character
401
+ }
402
+ return trimName(kept.replace(NAME_DANGLING, ''))
403
+ }
404
+
405
+ // One file or folder name made safe for macOS, Linux, Windows and Obsidian
406
+ // links, or '' when nothing is left of it. The author's casing and words are
407
+ // kept; see "File names" in docs/obsidian-contract.md for each rule.
408
+ export function vaultName(value, maxBytes = NAME_BYTES) {
409
+ const cleaned = trimName(String(value ?? '')
410
+ .toWellFormed()
411
+ .normalize('NFC')
412
+ .replace(NAME_INVISIBLE, '')
413
+ .replace(NAME_FORBIDDEN, ' ')
414
+ .replace(/ {2,}/g, ' '))
415
+ const name = cutName(cleaned, maxBytes)
416
+ if (name === '' || !WINDOWS_DEVICE_NAMES.test(name.split('.')[0].trimEnd())) return name
417
+ return `_${cutName(name, maxBytes - 1)}`
418
+ }
419
+
420
+ export function fileNameOf(sourcePath) {
421
+ return String(sourcePath ?? '').split('/').at(-1)
422
+ }
423
+
424
+ // A node's extension: the canonical record's, else its path's.
425
+ export function extensionOf(node) {
426
+ if (typeof node?.extension === 'string' && node.extension !== '') return node.extension
427
+ const name = fileNameOf(node?.path)
428
+ const dot = name.lastIndexOf('.')
429
+ return dot > 0 ? name.slice(dot + 1) : ''
430
+ }
431
+
432
+ export function fileStemOf(sourcePath) {
433
+ const name = fileNameOf(sourcePath)
434
+ const dot = name.lastIndexOf('.')
435
+ return dot > 0 ? name.slice(0, dot) : name
436
+ }
437
+
438
+ // A title that ends in the source's own extension (`notes.md`) came from a file
439
+ // name; the extension is dropped. Only the source's own extension: `Node.js`
440
+ // or `Version 2.0` keep their ending.
441
+ function withoutOwnExtension(title, extension) {
442
+ if (!/^[A-Za-z0-9]{1,16}$/.test(extension)) return title
443
+ const trimmed = title.trimEnd()
444
+ const suffix = `.${extension}`
445
+ const before = trimmed.length - suffix.length
446
+ if (before < 1 || trimmed.slice(before).toLowerCase() !== suffix.toLowerCase() || /\s/.test(trimmed[before - 1])) return title
447
+ return trimmed.slice(0, before)
448
+ }
449
+
450
+ // The name of a Markdown note, from the node record alone: its canonical
451
+ // title (front-matter title, else first H1), or the file stem as written when
452
+ // the title is the graph's fallback, the title-cased file name. Never ''.
453
+ export function noteNameOf(node) {
454
+ const fileName = fileNameOf(node.path)
455
+ const stem = fileStemOf(node.path)
456
+ const title = typeof node.title === 'string' ? node.title : ''
457
+ const chosen = title.trim() === '' || title === titleCase(fileName) ? stem : withoutOwnExtension(title, extensionOf(node))
458
+ return vaultName(chosen) || vaultName(stem) || 'Untitled'
459
+ }
460
+
461
+ // A wrapped or embedded file keeps its own name: `{ stem, extension }`, made
462
+ // safe, the stem bounded so that the extension still fits. Never an empty stem.
463
+ export function fileNameParts(sourcePath) {
464
+ const name = fileNameOf(sourcePath)
465
+ const dot = name.lastIndexOf('.')
466
+ const extension = dot > 0 ? vaultName(name.slice(dot + 1), 16) : ''
467
+ const budget = NAME_BYTES - (extension === '' ? 0 : Buffer.byteLength(extension, 'utf8') + 1)
468
+ const stem = vaultName(dot > 0 ? name.slice(0, dot) : name, budget) || 'Untitled'
469
+ return { stem, extension }
470
+ }
471
+
472
+ export const joinFileName = ({ stem, extension }) => (extension === '' ? stem : `${stem}.${extension}`)
473
+
474
+ // A folder segment of the mirrored source directory, or the repository folder.
475
+ export function folderNameOf(segment) {
476
+ return vaultName(segment, FOLDER_NAME_BYTES) || '_'
477
+ }
478
+
479
+ // The stable short identity a colliding name is qualified with: hexadecimal
480
+ // characters of the SHA-256 of the repository and node identity (the digest
481
+ // the layout 1 suffix is a prefix of).
482
+ export function qualifierIdOf(repoId, nodeId, length = 6) {
483
+ return createHash('sha256').update(`${repoId}\u0000${nodeId}`).digest('hex').slice(0, length)
484
+ }
485
+
486
+ // Whether `value` is a path of layout 2: relative, every segment a name the
487
+ // rules above can produce (no forbidden character, no leading or trailing
488
+ // space or dot, at most 255 bytes).
489
+ const HAS_FORBIDDEN = new RegExp(NAME_FORBIDDEN.source)
490
+ const HAS_INVISIBLE = new RegExp(NAME_INVISIBLE.source)
491
+
492
+ export function isReadableVaultPath(value) {
493
+ if (typeof value !== 'string' || value === '' || Buffer.byteLength(value, 'utf8') > 1024) return false
494
+ return value.split('/').every((segment) => segment !== ''
495
+ && trimName(segment) === segment
496
+ && !HAS_FORBIDDEN.test(segment)
497
+ && !HAS_INVISIBLE.test(segment)
498
+ && !WINDOWS_DEVICE_NAMES.test(segment.split('.')[0].trimEnd())
499
+ && segment === segment.normalize('NFC')
500
+ && Buffer.byteLength(segment, 'utf8') <= 255)
501
+ }
502
+
503
+ // The layout a generation manifest was prepared in.
504
+ export function manifestLayoutVersion(manifest) {
505
+ return manifest?.schema === 'atelier-obsidian-generation-manifest/v2' ? manifest.layoutVersion : 1
506
+ }
507
+
508
+ // The three generated properties that name a note's identity, in order.
509
+ export const IDENTITY_KEYS = Object.freeze(['atelier-id', 'atelier-repo', 'atelier-source'])
510
+
511
+ // A YAML double-quoted scalar: JSON's escapes, plus `\u` for what YAML does
512
+ // not allow unescaped (DEL, C1 controls, U+2028, U+2029, U+FEFF).
513
+ export function yamlQuoted(value) {
514
+ return JSON.stringify(String(value)).replace(/[\u007f-\u009f\u2028\u2029\ufeff]/g, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`)
515
+ }
516
+
517
+ // The identity lines of one note, without line endings.
518
+ export function identityLineTexts(node) {
519
+ return [[IDENTITY_KEYS[0], node.id], [IDENTITY_KEYS[1], node.repo], [IDENTITY_KEYS[2], node.path]].map(([key, value]) => `${key}: ${yamlQuoted(value)}`)
520
+ }
521
+
316
522
  // ---------------------------------------------------------------------------
317
523
  // Canonical snapshot and profile views
318
524
  // ---------------------------------------------------------------------------
@@ -11,7 +11,7 @@ import { ObsidianMaintenanceRefusal } from '../../../runtime/obsidian/errors.mjs
11
11
  import { protectedRoots, readInstalledApplyPolicy, readLocalPointer, readMachineSettings, resolveDataRoot, workspaceStateRoot } from '../../../runtime/obsidian/machine-settings.mjs'
12
12
  import { DEFAULT_ELIGIBILITY, createProductionSeams } from '../../../runtime/obsidian/pipeline.mjs'
13
13
  import { OPEN_EDIT_STATES, createMaintenanceStateStore } from '../../../runtime/obsidian/state-store.mjs'
14
- import { OBSIDIAN_EXT_KEY, ObsidianContractRefusal } from '../contracts.mjs'
14
+ import { OBSIDIAN_EXT_KEY, ObsidianContractRefusal, manifestLayoutVersion } from '../contracts.mjs'
15
15
  import { readMarkdownLens } from '../materialize/byte-lens.mjs'
16
16
  import { exchangeFiles, probeExchange } from '../publication/exchange.mjs'
17
17
  import { createJournal, newJournalId } from '../recovery/journal.mjs'
@@ -338,22 +338,27 @@ export function createSourceApplyForOracleTests(primitives = SOURCE_APPLY_PRIMIT
338
338
  }
339
339
 
340
340
  // The note exactly as it was published, which the lens needs to tell generated bytes from authored ones. The
341
- // publisher keeps no copy of a note it created, so the note is prepared again from the sources as they are now
342
- // and used only when it has the digest the manifest recorded; a retained object of that digest serves as well.
341
+ // publisher keeps no copy of a note it created, so the note is prepared again from the sources as they are now,
342
+ // in the vault layout of that generation, and used only when it has the digest the manifest recorded; a retained
343
+ // object of that digest serves as well.
343
344
  function publishedNoteOf(workspace, { scope, manifest, noteEntry }) {
344
345
  const store = workspace.storeOf(scope.scopeId)
345
346
  try { return store.readObject(noteEntry.noteDigest) } catch (error) { if (error.code !== 'ENOENT' && error.code !== 'recovery-object-corrupt') throw error }
346
347
  workspace.prepared ??= new Map()
347
- if (!workspace.prepared.has(scope.scopeId)) {
348
+ const preparedKey = `${scope.scopeId}\u0000${manifest.generationId}`
349
+ if (!workspace.prepared.has(preparedKey)) {
348
350
  let files = []
349
351
  try {
350
352
  const { graph, profile } = currentCorpus(workspace)
351
353
  const snapshot = seams.captureSnapshot({ project: workspace.project, graph, workspaceId: workspace.workspaceId, index: new Map(), configDigest: manifest.ext?.[EXT]?.configDigest ?? `sha256:${'0'.repeat(64)}`, capturedAt: isoTime(clock) })
352
- files = seams.prepareView({ snapshot, profile, scope, persistentPathRegistry: workspace.stateStore.readPathRegistry(), priorManifest: manifest, existingSettings: null, clock, vaultRootBytes: Buffer.byteLength(store.vaultRoot, 'utf8') }).files
354
+ files = seams.prepareView({
355
+ snapshot, profile, scope, persistentPathRegistry: workspace.stateStore.readPathRegistry(), priorManifest: manifest, existingSettings: null, clock,
356
+ vaultRootBytes: Buffer.byteLength(store.vaultRoot, 'utf8'), layout: manifestLayoutVersion(manifest),
357
+ }).files
353
358
  } catch (error) { if (!isTyped(error) && !GONE.has(error?.code) && !UNREADABLE.has(error?.code)) throw error }
354
- workspace.prepared.set(scope.scopeId, files)
359
+ workspace.prepared.set(preparedKey, files)
355
360
  }
356
- const file = workspace.prepared.get(scope.scopeId).find((item) => item.path === noteEntry.path && item.digest === noteEntry.noteDigest)
361
+ const file = workspace.prepared.get(preparedKey).find((item) => item.path === noteEntry.path && item.digest === noteEntry.noteDigest)
357
362
  return file ? file.bytes : null
358
363
  }
359
364