@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
@@ -1,3 +1,4 @@
1
+ import { createCooperativeBudget, isSchedulingStop } from '../materialize/prepare-view.mjs'
1
2
  import fs from 'node:fs'
2
3
  import fsp from 'node:fs/promises'
3
4
  import path from 'node:path'
@@ -317,11 +318,33 @@ export function planUnits({ files, priorManifest, pointer, ledger, pluginDisk =
317
318
  return units
318
319
  }
319
320
 
321
+ // The code of a failure met while candidates were being cleaned up after a staging that did not complete, for the detail
322
+ // of what ended it. Nothing else of the failure is kept: its message may name a path.
323
+ const cleanupCauseOf = (failure) => (failure === null ? {} : { cleanupCause: typeof failure?.code === 'string' && failure.code !== '' ? failure.code.slice(0, 64) : 'unknown' })
324
+ // The stop or refusal itself is handed on, so the caller can still tell it by identity: its `detail` is replaced by a
325
+ // copy that adds `cleanupCause`, and an object shared as its detail is left as it was. A caller that throws the same
326
+ // error object again keeps that field on it.
327
+ function withCleanupCause(error, failure) {
328
+ if (failure !== null) try { error.detail = { ...(error.detail ?? {}), ...cleanupCauseOf(failure) } } catch { /* an error that cannot be extended is handed on as it is */ }
329
+ return error
330
+ }
331
+
320
332
  // The rules the oracle tests are sensitive to. Production always uses these; the tests substitute a deliberately
321
333
  // broken one through publishViewForOracleTests to prove the oracle can fail.
322
334
  export const PUBLICATION_PRIMITIVES = Object.freeze({
323
335
  // The one operation the `direct-unheld` path writes into the vault: a file created where nothing is.
324
336
  unheldWrites: (op) => op === 'create',
337
+ // Whether an error that stopped staging was the caller's stop (its settings check, or a cancellation), handed on
338
+ // under its own code once the candidates are cleaned up, rather than reported as a staging failure.
339
+ callerStoppedStaging: (error) => isSchedulingStop(error),
340
+ // Whether a refusal raised while candidates are being staged (the vault's folder replaced or gone under the run) is
341
+ // handed on only once those candidates are cleaned up, as a stop is. A candidate staged before the journal is
342
+ // written is named by no journal, so restart recovery would never remove one left there.
343
+ refusedStagingCleansUp: () => true,
344
+ // The code the journal's last entry gives an error that ended the publication once the journal was open: a
345
+ // caller's stop under its own code, which is what the caller is told, and anything else as a fault of the publisher.
346
+ // Decided by the tag a stop carries, never by what an error looks like; a stop that carries no code has none to give.
347
+ journalFailureCode: (error) => (isSchedulingStop(error) && typeof error.code === 'string' && error.code !== '' ? error.code.slice(0, 200) : 'publisher-error'),
325
348
  })
326
349
 
327
350
  // Whether the `direct-unheld` path applies, after the adapter found an app it cannot coordinate with (see the head of
@@ -355,6 +378,8 @@ export async function publishViewForOracleTests(options = {}, primitives = PUBLI
355
378
  if (!adapter || typeof adapter.probe !== 'function') throw new TypeError('publishView needs an editor coordination adapter')
356
379
  if (expectedGeneration !== null && typeof expectedGeneration !== 'string') throw new TypeError('expectedGeneration must be a generation identity or null')
357
380
 
381
+ const budget = createCooperativeBudget(options.scheduling)
382
+ budget.check()
358
383
  const releases = []
359
384
  let journal = null
360
385
  try {
@@ -384,9 +409,16 @@ export async function publishViewForOracleTests(options = {}, primitives = PUBLI
384
409
  store.checkAllocatedVault?.()
385
410
  acquire(store.vaultLockPath, () => acquireVaultLock(store), 'into this vault')
386
411
  store.checkAllocatedVault?.()
412
+ await budget.checkpoint('recover', true)
413
+ store.checkAllocatedVault?.()
387
414
  const recovered = recoverPublicationsLocked({ store, clock })
415
+ await budget.checkpoint('recovery-complete', true)
416
+ store.checkAllocatedVault?.()
388
417
  const pointer = store.readCurrent()
389
418
  if (pointer?.generationId === manifest.generationId && !pluginFilesDrifted(preparedView, store)) {
419
+ await options.beforeCommit?.()
420
+ budget.check()
421
+ store.checkAllocatedVault?.()
390
422
  return { state: 'committed', alreadyCommitted: true, generationId: manifest.generationId, journalId: pointer.journalId, notes: [], retainedEdits: pointer.retained ?? [], lateWriters: [], recovered }
391
423
  }
392
424
  if ((pointer?.generationId ?? null) !== expectedGeneration) refuse('generation-mismatch', 'the committed generation is not the one this publication expects', { committed: pointer?.generationId ?? null })
@@ -426,12 +458,16 @@ export async function publishViewForOracleTests(options = {}, primitives = PUBLI
426
458
  stagingDir = store.stagingDir(journalId)
427
459
  // On the `direct-unheld` path a replacement is never written, so no candidate is staged for one.
428
460
  for (const unit of units.filter((item) => item.op === 'create' || (item.op === 'replace' && (mode !== 'direct-unheld' || rules.unheldWrites(item.op))))) {
461
+ await budget.checkpoint('stage-candidate')
462
+ store.checkAllocatedVault?.()
429
463
  const existing = fs.lstatSync(path.join(store.vaultRoot, unit.path), { throwIfNoEntry: false })
430
464
  unit.preparedPath = store.preparedPath(journalId, unit.unit)
431
465
  stageCandidate(unit.preparedPath, unit.bytes, unit.mode ?? (existing?.isFile() ? existing.mode & 0o777 : 0o644), created)
432
466
  // A replacement is exchanged, and the exchange leaves the displaced bytes at this path: it is in recovery.
433
467
  unit.stagedPath = unit.op === 'replace' ? store.exchangeCandidatePath(journalId, unit.unit) : unit.preparedPath
434
468
  }
469
+ await budget.checkpoint('staging-complete', true)
470
+ store.checkAllocatedVault?.()
435
471
  syncPrivateDirectory(stagingDir)
436
472
  const manifestFile = path.join(store.journalDir(journalId), 'manifest.json')
437
473
  publishPrivateFile(manifestFile, manifestBytes)
@@ -446,22 +482,39 @@ export async function publishViewForOracleTests(options = {}, primitives = PUBLI
446
482
  // The journal now names every exchange candidate by path and digest. Only complete, fsynced files are moved
447
483
  // in, so a file at an exchange path is never a partial candidate.
448
484
  for (const unit of units.filter((item) => item.stagedPath && item.stagedPath !== item.preparedPath)) {
485
+ await budget.checkpoint('move-candidate')
486
+ store.checkAllocatedVault?.()
449
487
  moveToExchangePath(store, journalId, unit, moved)
450
488
  }
451
489
  } catch (error) {
452
- if (error instanceof PublicationRefusal) throw error
490
+ const stopped = rules.callerStoppedStaging(error)
491
+ // A refusal raised between two candidates (the publisher's own check found the vault's folder replaced or gone)
492
+ // is handed on under its own code too, and like a stop only after the cleanup below.
493
+ const refused = error instanceof PublicationRefusal && !stopped
494
+ if (refused && !rules.refusedStagingCleansUp(error)) throw error
453
495
  // No payload has named any of these paths, so nothing was exchanged and they can only hold our own
454
496
  // candidates. In staging they are removed by name. In recovery a file is removed only when its bytes are
455
497
  // the candidate's; anything else stays for restart recovery, which the journal header points at it. Only
456
498
  // empty directories are removed, so a failed staging leaves nothing that looks like an unfinished unit.
499
+ // Each file is cleaned up on its own, so one that cannot be removed (EBUSY, EPERM, a volume gone) leaves the
500
+ // others removed; the first such failure is kept.
501
+ let cleanupFailure = null
502
+ const cleanUp = (operation) => { try { operation() } catch (failure) { cleanupFailure ??= failure } }
457
503
  for (const unit of moved) {
458
- if (readFileDigest(unit.stagedPath) === unit.candidateDigest) fs.rmSync(unit.stagedPath, { force: true })
504
+ cleanUp(() => { if (readFileDigest(unit.stagedPath) === unit.candidateDigest) fs.rmSync(unit.stagedPath, { force: true }) })
459
505
  try { fs.rmdirSync(path.dirname(unit.stagedPath)) } catch { /* not empty: left for restart recovery */ }
460
506
  }
461
- for (const file of created) fs.rmSync(file, { force: true })
507
+ for (const file of created) cleanUp(() => fs.rmSync(file, { force: true }))
462
508
  for (const directory of [stagingDir, store.unitsRoot(journalId)]) if (directory) try { fs.rmdirSync(directory) } catch { /* absent, or not empty */ }
463
- refuse('staging-failed', 'the candidates could not be staged; nothing in the vault was touched', { cause: error.code ?? String(error.message) })
509
+ // The caller stopped the publication between two candidates: nothing failed to stage, and its reason (a
510
+ // setting that changed during the tick, `mixed-read`, or a cancellation) is the one reported. So is a refusal's.
511
+ // A cleanup that failed does not replace either: it is named in their detail as `cleanupCause`, and what it could
512
+ // not remove stays where it is.
513
+ if (stopped || refused) throw withCleanupCause(error, cleanupFailure)
514
+ refuse('staging-failed', 'the candidates could not be staged; nothing in the vault was touched', { cause: error.code ?? String(error.message), ...cleanupCauseOf(cleanupFailure) })
464
515
  }
516
+ await budget.checkpoint('journal-and-moves-complete', true)
517
+ store.checkAllocatedVault?.()
465
518
  crash('after-staging')
466
519
 
467
520
  const context = { store, journal, journalId, channel, clock, crash, mode, rules }
@@ -479,6 +532,8 @@ export async function publishViewForOracleTests(options = {}, primitives = PUBLI
479
532
  // unit refuses, a kept one included, so nothing is committed.
480
533
  let lastProbe = null
481
534
  for (const unit of units) {
535
+ await budget.checkpoint('publish-unit')
536
+ store.checkAllocatedVault?.()
482
537
  if (mode === 'direct' && (lastProbe === null || Date.now() - lastProbe > 2000)) {
483
538
  lastProbe = Date.now()
484
539
  const again = await adapter.probe({ vaultRoot: store.vaultRoot })
@@ -501,6 +556,8 @@ export async function publishViewForOracleTests(options = {}, primitives = PUBLI
501
556
  }
502
557
  }
503
558
 
559
+ await budget.checkpoint('publication-complete', true)
560
+ store.checkAllocatedVault?.()
504
561
  const blocking = results.filter((result) => result.blocking)
505
562
  const retainedEdits = results.filter((result) => result.retained).map((result) => result.retained)
506
563
  // Notes kept earlier stay surfaced until they leave the vault or come back into a view.
@@ -517,6 +574,16 @@ export async function publishViewForOracleTests(options = {}, primitives = PUBLI
517
574
  journal.append({ step: 'verify', outcome: 'conflict', state: 'verifying', notePath: finding.notePath, beforeDigest: finding.digestAtMove, afterDigest: finding.observedDigest, recoveryRef: finding.displacedRef,
518
575
  detail: { unit: finding.unit, code: 'late-writer-captured', objectRef: finding.objectRef } })
519
576
  }
577
+ await budget.checkpoint('verify-before-commit', true)
578
+ // The resident engine revalidates the selected source pins here, while
579
+ // publication still owns its locks. Its check may yield; no durability
580
+ // unit or committed pointer is half-written across that wait. It comes
581
+ // before the entry that says the publication settled: restart recovery
582
+ // commits a journal that holds that entry, so a refusal after it would be
583
+ // committed by the next publication of this view.
584
+ await options.beforeCommit?.()
585
+ budget.check()
586
+ store.checkAllocatedVault?.()
520
587
  journal.append({ step: 'verify', outcome: 'ok', state: 'verifying', detail: { settled: true, retained: retainedEdits } })
521
588
  crash('before-manifest-commit')
522
589
  store.checkAllocatedVault?.()
@@ -528,7 +595,7 @@ export async function publishViewForOracleTests(options = {}, primitives = PUBLI
528
595
  return { ...base, state: 'committed', lateWriters }
529
596
  } catch (error) {
530
597
  if (!(error instanceof PublicationRefusal)) {
531
- if (journal) try { journal.append({ step: 'verify', outcome: 'failed', state: 'failed', detail: { code: 'publisher-error', message: String(error.code ?? error.message).slice(0, 200) } }) } catch { /* the journal itself cannot be written */ }
598
+ if (journal) try { journal.append({ step: 'verify', outcome: 'failed', state: 'failed', detail: { code: rules.journalFailureCode(error), message: String(error.code ?? error.message).slice(0, 200) } }) } catch { /* the journal itself cannot be written */ }
532
599
  throw error
533
600
  }
534
601
  return { state: 'refused', refusal: { code: error.code, message: error.message, detail: error.detail }, notes: [], retainedEdits: [], lateWriters: [] }
@@ -3,6 +3,7 @@ import { isDeepStrictEqual } from 'node:util'
3
3
  import path from 'node:path'
4
4
  import { commandProject, readJson, writeJson } from '../project/config.mjs'
5
5
  import { summarizeReadinessJourney } from '../readiness-protocols/runtime.mjs'
6
+ import { atelierControlStyles } from '../ui/control-system.mjs'
6
7
 
7
8
  const esc = (value) => String(value ?? '').replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;')
8
9
 
@@ -53,7 +54,7 @@ export function buildProjectProjection(project) {
53
54
  <h3>${esc(item.title)}</h3>
54
55
  <p><strong>${esc(item.status)}</strong> · ${esc(item.score)} / 100 answers complete</p>
55
56
  <p>${esc(item.blockers[0] || 'review-ready')}</p>
56
- <button type="button" data-copy="${esc(item.agentPrompt)}">Copy protocol prompt</button>
57
+ <button type="button" class="system-control" data-variant="secondary" data-size="compact" data-copy="${esc(item.agentPrompt)}">Copy protocol prompt</button>
57
58
  </article>`).join('\n')
58
59
  const cards = graph.nodes.map((node) => `<article class="card" data-node="${esc(node.id)}">
59
60
  <p class="eyebrow">${esc(node.repo)} · ${esc(node.type)} · ${esc(node.audience)}</p>
@@ -80,9 +81,9 @@ h1{font-size:clamp(2rem,4vw,4rem);margin:.2em 0}
80
81
  .readiness-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin-top:16px}
81
82
  .readiness-card{border:1px solid #342b26;border-radius:8px;padding:14px;background:#171312}
82
83
  .readiness-card h3{font-size:1.05rem;margin:.2rem 0}
83
- .readiness-card button{min-height:44px;border:1px solid #755f43;border-radius:6px;background:#241d18;color:var(--atelier-text);padding:8px 10px;cursor:pointer}
84
84
  .eyebrow{color:var(--atelier-eyebrow);text-transform:uppercase;letter-spacing:.08em;font-size:.8rem}
85
85
  a{color:var(--atelier-accent)}
86
+ ${atelierControlStyles}
86
87
  ${branding.themeCss}</style>
87
88
  </head>
88
89
  <body><main>