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

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 (253) hide show
  1. package/CHANGELOG.md +283 -0
  2. package/README.md +102 -52
  3. package/SECURITY.md +1 -1
  4. package/TRADEMARKS.md +2 -2
  5. package/contracts/atelier-extension-manifest.v1.schema.json +412 -0
  6. package/contracts/atelier-graph-view.v1.schema.json +1085 -0
  7. package/contracts/public-api-baseline.json +298 -19
  8. package/docs/README.md +90 -0
  9. package/docs/blocks/capability-stewardship.md +1 -1
  10. package/docs/blocks/claims.md +2 -2
  11. package/docs/blocks/conformance-admission.md +1 -1
  12. package/docs/blocks/learning-harnesses.md +1 -1
  13. package/docs/{atelier-sync.md → guides/atelier-sync.md} +20 -14
  14. package/docs/{capability-stewardship.md → guides/capability-stewardship.md} +2 -4
  15. package/docs/guides/co-authoring.md +113 -0
  16. package/docs/{distributions.md → guides/distributions.md} +13 -8
  17. package/docs/guides/extensions.md +189 -0
  18. package/docs/guides/graph-refresh.md +152 -0
  19. package/docs/guides/graph-workspace.md +64 -0
  20. package/docs/{ingestion.md → guides/ingestion.md} +16 -15
  21. package/docs/{inquiry-harnesses.md → guides/inquiry-harnesses.md} +2 -3
  22. package/docs/{knowledge-workspace.md → guides/knowledge-workspace.md} +2 -2
  23. package/docs/{learning-harnesses.md → guides/learning-harnesses.md} +2 -3
  24. package/docs/{learning.md → guides/learning.md} +12 -5
  25. package/docs/{obsidian.md → guides/obsidian.md} +370 -23
  26. package/docs/guides/ontology-guide.md +111 -0
  27. package/docs/{personal-workspace.md → guides/personal-workspace.md} +17 -5
  28. package/docs/{research-starter.md → guides/research-starter.md} +1 -1
  29. package/docs/{tenant-readiness.md → guides/tenant-readiness.md} +39 -0
  30. package/docs/{upgrade.md → guides/upgrade.md} +197 -11
  31. package/docs/knowledge-health/assessment/capabilities.md +6 -15
  32. package/docs/knowledge-health/facilitator.md +15 -4
  33. package/docs/knowledge-health/{devday/participant-response.md → participant-response.md} +4 -2
  34. package/docs/knowledge-health/quickstart.md +97 -75
  35. package/docs/knowledge-health/readme.md +13 -5
  36. package/docs/maintainers/describing-the-atelier.md +173 -0
  37. package/docs/{release-engineering.md → maintainers/release-engineering.md} +5 -4
  38. package/docs/{announcements.md → reference/announcements.md} +1 -1
  39. package/docs/{architecture.md → reference/architecture.md} +31 -5
  40. package/docs/{atelier-runtime.md → reference/atelier-runtime.md} +1 -1
  41. package/docs/{connected-composition.md → reference/connected-composition.md} +7 -10
  42. package/docs/{coordination.md → reference/coordination.md} +1 -1
  43. package/docs/{decisions.md → reference/decisions.md} +3 -3
  44. package/docs/{design.md → reference/design.md} +1 -1
  45. package/docs/reference/edge-policy.md +151 -0
  46. package/docs/reference/graph-view-structured.md +119 -0
  47. package/docs/reference/graph-view.md +139 -0
  48. package/docs/reference/graph-workspace.md +37 -0
  49. package/docs/{intake-and-guides.md → reference/intake-and-guides.md} +3 -4
  50. package/docs/{knowledge-graph.md → reference/knowledge-graph.md} +120 -6
  51. package/docs/{local-services.md → reference/local-services.md} +43 -4
  52. package/docs/{obsidian-contract.md → reference/obsidian-contract.md} +29 -4
  53. package/docs/{obsidian-plugin.md → reference/obsidian-plugin.md} +218 -68
  54. package/docs/{practical-judgment.md → reference/phronesis.md} +12 -3
  55. package/docs/reference/portability.md +92 -0
  56. package/docs/{portable-presentation.md → reference/portable-presentation.md} +2 -2
  57. package/docs/{presentation-consumer-boundaries.md → reference/presentation-consumer-boundaries.md} +5 -0
  58. package/docs/{project-options.md → reference/project-options.md} +17 -0
  59. package/docs/{repo-boundary-guard.md → reference/repo-boundary-guard.md} +50 -4
  60. package/docs/{review-portability.md → reference/review-portability.md} +4 -0
  61. package/docs/{semantic-operation.md → reference/semantic-operation.md} +4 -3
  62. package/docs/{template-profile.md → reference/template-profile.md} +1 -1
  63. package/docs/{trackables.md → reference/trackables.md} +2 -2
  64. package/docs/start/independent-start.md +89 -0
  65. package/docs/{install.md → start/install.md} +9 -9
  66. package/docs/{knowledge-setup.md → start/knowledge-setup.md} +5 -5
  67. package/docs/{research-starter-quickstart.md → start/research-starter-quickstart.md} +6 -6
  68. package/fixtures/atelier-architecture/catalog/invalid/document.json +2 -2
  69. package/fixtures/atelier-architecture/catalog/valid/document.json +2 -2
  70. package/fixtures/graph-view/synthetic-authorized-input.v1.json +437 -0
  71. package/fixtures/graph-view/synthetic-view.v1.json +1 -0
  72. package/fixtures/knowledge-health/workshop/README.md +1 -1
  73. package/fixtures/templates/local-library.v1.json +3 -1
  74. package/package.json +22 -8
  75. package/plugins/obsidian/main.js +57 -8
  76. package/plugins/obsidian/manifest.json +1 -1
  77. package/skills/claude/atelier-guided-upgrade/SKILL.md +1 -1
  78. package/skills/claude/atelier-knowledge-setup/SKILL.md +2 -2
  79. package/skills/claude/atelier-local-service/SKILL.md +1 -1
  80. package/skills/claude/atelier-practical-judgment/SKILL.md +7 -2
  81. package/skills/claude/atelier-skill-steward/SKILL.md +1 -1
  82. package/skills/claude/mnstry-readiness/SKILL.md +2 -2
  83. package/skills/codex/atelier-guided-upgrade/SKILL.md +1 -1
  84. package/skills/codex/atelier-knowledge-setup/SKILL.md +2 -2
  85. package/skills/codex/atelier-local-service/SKILL.md +1 -1
  86. package/skills/codex/atelier-practical-judgment/SKILL.md +7 -2
  87. package/skills/codex/atelier-skill-steward/SKILL.md +1 -1
  88. package/skills/codex/mnstry-readiness/SKILL.md +2 -2
  89. package/src/architecture/catalog.mjs +2 -2
  90. package/src/attestation/sign.mjs +1 -1
  91. package/src/boundary/content-rules.mjs +534 -68
  92. package/src/boundary/policy.mjs +187 -60
  93. package/src/cli/run.mjs +26 -9
  94. package/src/commands/distribution.mjs +1 -1
  95. package/src/commands/extension.mjs +27 -0
  96. package/src/commands/ingest.mjs +1 -1
  97. package/src/commands/learn.mjs +1 -1
  98. package/src/commands/obsidian.mjs +49 -21
  99. package/src/commands/review.mjs +3 -0
  100. package/src/edge-policy/contract.mjs +69 -0
  101. package/src/edge-policy/policy.mjs +142 -0
  102. package/src/edge-policy/source-citation-adapter.mjs +43 -0
  103. package/src/extension-packs/loader.mjs +4 -3
  104. package/src/extensions/admission.mjs +83 -0
  105. package/src/extensions/client.d.mts +23 -0
  106. package/src/extensions/client.mjs +99 -0
  107. package/src/extensions/contracts.d.mts +29 -0
  108. package/src/extensions/contracts.mjs +163 -0
  109. package/src/extensions/convex.d.mts +24 -0
  110. package/src/extensions/convex.mjs +189 -0
  111. package/src/extensions/delegation.mjs +54 -0
  112. package/src/extensions/drizzle.d.mts +11 -0
  113. package/src/extensions/drizzle.mjs +33 -0
  114. package/src/extensions/postgres.d.mts +7 -0
  115. package/src/extensions/postgres.mjs +66 -0
  116. package/src/extensions/prisma.d.mts +13 -0
  117. package/src/extensions/prisma.mjs +63 -0
  118. package/src/extensions/project.mjs +432 -0
  119. package/src/extensions/scaffold-convex.mjs +130 -0
  120. package/src/extensions/scaffold-sql.mjs +144 -0
  121. package/src/extensions/server.d.mts +20 -0
  122. package/src/extensions/server.mjs +102 -0
  123. package/src/extensions/sql-scope.mjs +22 -0
  124. package/src/graph/graph.mjs +13 -5
  125. package/src/graph/knowledge-graph.mjs +20 -2
  126. package/src/graph/repo-contribution.mjs +405 -0
  127. package/src/graph/repo-contribution.types.mjs +32 -0
  128. package/src/graph-access/edge-decision-adapter.mjs +41 -0
  129. package/src/graph-access/edge-record-adapter.mjs +73 -0
  130. package/src/graph-access/index.mjs +253 -0
  131. package/src/graph-access/multi-contribution-record-adapter.mjs +101 -0
  132. package/src/graph-access/multi-record-projection-adapter.mjs +74 -0
  133. package/src/graph-access/source-adapter.mjs +81 -0
  134. package/src/graph-refresh/compare.mjs +110 -0
  135. package/src/graph-refresh/controller.mjs +324 -0
  136. package/src/graph-refresh/index.mjs +3 -0
  137. package/src/graph-refresh/local.mjs +78 -0
  138. package/src/graph-view/babylon/babylon-scene-port.mjs +64 -0
  139. package/src/graph-view/babylon/index.mjs +2 -0
  140. package/src/graph-view/babylon/navigator.mjs +312 -0
  141. package/src/graph-view/babylon/provenance.mjs +36 -0
  142. package/src/graph-view/babylon/scene-controller.mjs +131 -0
  143. package/src/graph-view/babylon/semantic-picking.mjs +35 -0
  144. package/src/graph-view/babylon/web-host.mjs +188 -0
  145. package/src/graph-view/cli.mjs +3 -0
  146. package/src/graph-view/command.mjs +34 -0
  147. package/src/graph-view/contracts.mjs +79 -0
  148. package/src/graph-view/errors.mjs +5 -0
  149. package/src/graph-view/index.mjs +4 -0
  150. package/src/graph-view/intents.mjs +30 -0
  151. package/src/graph-view/query.mjs +213 -0
  152. package/src/graph-view/refs.mjs +2 -0
  153. package/src/graph-view/structured/browser.mjs +262 -0
  154. package/src/graph-view/structured/styles.mjs +8 -0
  155. package/src/graph-workspace/authoring.mjs +185 -0
  156. package/src/graph-workspace/browser/constellation.mjs +369 -0
  157. package/src/graph-workspace/browser/dom.mjs +23 -0
  158. package/src/graph-workspace/browser/index.mjs +6 -0
  159. package/src/graph-workspace/browser/review.mjs +55 -0
  160. package/src/graph-workspace/browser/styles.mjs +11 -0
  161. package/src/graph-workspace/browser/workbench.mjs +351 -0
  162. package/src/graph-workspace/browser/workspace.mjs +431 -0
  163. package/src/graph-workspace/disclosure.mjs +39 -0
  164. package/src/graph-workspace/geometry.mjs +78 -0
  165. package/src/graph-workspace/index.mjs +11 -0
  166. package/src/graph-workspace/labels.mjs +50 -0
  167. package/src/graph-workspace/model.mjs +109 -0
  168. package/src/graph-workspace/recipes.mjs +88 -0
  169. package/src/graph-workspace/scale.mjs +95 -0
  170. package/src/graph-workspace/theme.mjs +156 -0
  171. package/src/ingestion/optional/graphify/graphify_supplier.py +5 -0
  172. package/src/ingestion/optional/graphify/native-closure-pins.json +275 -0
  173. package/src/ingestion/optional/graphify/native-pins.json +306 -0
  174. package/src/knowledge/ontology-guide.mjs +152 -0
  175. package/src/knowledge-health/proof/installed-check.mjs +2 -2
  176. package/src/knowledge-health/rule-identity.mjs +14 -9
  177. package/src/knowledge-health/workshop.mjs +22 -2
  178. package/src/personal-workspace/index.mjs +1 -1
  179. package/src/personal-workspace/selection.mjs +34 -3
  180. package/src/project/config.mjs +1 -1
  181. package/src/project/path-match.mjs +2 -1
  182. package/src/project/providers/github.mjs +24 -0
  183. package/src/project/repo-identity.mjs +3 -22
  184. package/src/projection/obsidian/contracts.mjs +1 -1
  185. package/src/projection/obsidian/materialize/path-registry.mjs +1 -1
  186. package/src/projection/obsidian/materialize/prepare-view.mjs +88 -6
  187. package/src/projection/obsidian/materialize/redaction.mjs +1 -1
  188. package/src/projection/obsidian/personal-workspace.mjs +84 -5
  189. package/src/projection/obsidian/plugin-bridge/channel.mjs +1 -1
  190. package/src/projection/obsidian/proposals/adapter.mjs +46 -23
  191. package/src/projection/obsidian/proposals/observation.mjs +40 -2
  192. package/src/projection/obsidian/publication/publisher.mjs +96 -8
  193. package/src/projection/project.mjs +21 -16
  194. package/src/readiness/readiness.mjs +20 -11
  195. package/src/readiness-protocols/bundled-pack.mjs +14 -766
  196. package/src/readiness-protocols/bundled-selection.mjs +32 -0
  197. package/src/readiness-protocols/evidence.mjs +2 -1
  198. package/src/readiness-protocols/packs/mnstry.mjs +766 -0
  199. package/src/readiness-protocols/runtime.mjs +43 -6
  200. package/src/runtime/obsidian/engine.mjs +387 -76
  201. package/src/runtime/obsidian/lifecycle.mjs +20 -15
  202. package/src/runtime/obsidian/login-item.mjs +6 -3
  203. package/src/runtime/obsidian/machine-settings.mjs +52 -8
  204. package/src/runtime/obsidian/observation.mjs +36 -4
  205. package/src/runtime/obsidian/opening.mjs +4 -3
  206. package/src/runtime/obsidian/personal-selection.mjs +187 -0
  207. package/src/runtime/obsidian/pipeline.mjs +115 -6
  208. package/src/runtime/obsidian/private-lock.mjs +50 -6
  209. package/src/runtime/obsidian/selection-continuity.mjs +124 -0
  210. package/src/runtime/obsidian/service-main.mjs +68 -16
  211. package/src/runtime/obsidian/service.mjs +80 -6
  212. package/src/runtime/obsidian/unheld-evidence.mjs +1 -1
  213. package/src/runtime/obsidian/view-permission.mjs +335 -0
  214. package/src/ui/control-system.mjs +201 -0
  215. package/src/ui/html-primitives.mjs +3 -0
  216. package/src/upgrade/closure-diagnostics.mjs +135 -0
  217. package/src/upgrade/closure.mjs +649 -0
  218. package/src/upgrade/upgrade.mjs +42 -11
  219. package/templates/distribution-workspace/README.md +2 -2
  220. package/templates/external-project-workspace/atelier.project.json +3 -0
  221. package/templates/external-project-workspace/pack-lifecycle.json +3 -1
  222. package/templates/knowledge-workspace/README.md +2 -2
  223. package/docs/integration-and-review-plan.md +0 -409
  224. package/docs/integration-contract-decisions.md +0 -44
  225. package/docs/knowledge-health/devday/facilitator.md +0 -16
  226. package/docs/knowledge-health/devday/public-default.md +0 -107
  227. package/docs/knowledge-health/devday/quickstart.md +0 -18
  228. package/docs/knowledge-health/devday/support.md +0 -22
  229. package/docs/knowledge-health/exercise.md +0 -17
  230. package/docs/knowledge-health/support.md +0 -34
  231. package/docs/presentation-review-disposition.md +0 -47
  232. package/docs/progressive-ingestion-acceptance.md +0 -89
  233. package/docs/progressive-ingestion.md +0 -235
  234. /package/docs/{exact-upgrades.md → guides/exact-upgrades.md} +0 -0
  235. /package/docs/{guided-upgrades.md → guides/guided-upgrades.md} +0 -0
  236. /package/docs/{local-review.md → guides/local-review.md} +0 -0
  237. /package/docs/{skill-steward.md → guides/skill-steward.md} +0 -0
  238. /package/docs/{template-upgrades.md → guides/template-upgrades.md} +0 -0
  239. /package/docs/{assurance-controls.md → maintainers/assurance-controls.md} +0 -0
  240. /package/docs/{attestation.md → reference/attestation.md} +0 -0
  241. /package/docs/{coauthor-session.md → reference/coauthor-session.md} +0 -0
  242. /package/docs/{continuity.md → reference/continuity.md} +0 -0
  243. /package/docs/{contract-stability.md → reference/contract-stability.md} +0 -0
  244. /package/docs/{decision-practices.md → reference/decision-practices.md} +0 -0
  245. /package/docs/{evidence-navigation.md → reference/evidence-navigation.md} +0 -0
  246. /package/docs/{extension-pack-lifecycle.md → reference/extension-pack-lifecycle.md} +0 -0
  247. /package/docs/{interaction-and-reflection.md → reference/interaction-and-reflection.md} +0 -0
  248. /package/docs/{local-state.md → reference/local-state.md} +0 -0
  249. /package/docs/{ontology.md → reference/ontology.md} +0 -0
  250. /package/docs/{optional-ingestion.md → reference/optional-ingestion.md} +0 -0
  251. /package/docs/{project-command-inventory.md → reference/project-command-inventory.md} +0 -0
  252. /package/docs/{semantic-ingestion.md → reference/semantic-ingestion.md} +0 -0
  253. /package/docs/{vault-service.md → reference/vault-service.md} +0 -0
@@ -0,0 +1,262 @@
1
+ import { structuredStyles } from './styles.mjs'
2
+ import { refKey } from '../refs.mjs'
3
+ import { assertGraphViewIntent } from '../intents.mjs'
4
+
5
+ const sameRef = (a, b) => Boolean(a && b && a.repoId === b.repoId && a.localId === b.localId)
6
+ const identity = ref => ({ repoId: ref.repoId, localId: ref.localId })
7
+ const refText = ref => `${ref.repoId} / ${ref.localId}`
8
+ const locatorText = locator => Object.entries(locator).map(([key, value]) => `${key}: ${typeof value === 'object' ? Object.entries(value).map(([name, number]) => `${name}=${number}`).join(', ') : value}`).join('; ')
9
+ const mounts = new WeakSet()
10
+ let nextMountId = 1
11
+
12
+ // A presentation consumer, never an authorization boundary. The host must deliver
13
+ // a validated reader-specific slice and remove it immediately on grant loss.
14
+ export function mountStructuredGraphView(root, { view = null, selection = null, refreshStatus = null, onIntent, onPage } = {}) {
15
+ if (!root?.ownerDocument || mounts.has(root)) throw new TypeError('structured graph root unavailable')
16
+ if (typeof onIntent !== 'function') throw new TypeError('structured graph requires an intent host')
17
+ mounts.add(root)
18
+ const doc = root.ownerDocument
19
+ const prefix = `agv-${nextMountId++}`
20
+ let current = null, selected = null, currentRefreshStatus = null, query = '', disposed = false, epoch = 0, request = 0
21
+ let ui, roving = null
22
+ const make = (tag, text, attrs = {}) => {
23
+ const element = doc.createElement(tag)
24
+ if (text !== null) element.textContent = text
25
+ for (const [name, value] of Object.entries(attrs)) element.setAttribute(name, String(value))
26
+ return element
27
+ }
28
+ const append = (parent, ...children) => { parent.append(...children); return parent }
29
+ const paragraph = (parent, text, className) => parent.append(make('p', text, className ? { class: className } : {}))
30
+ const heading = (parent, text) => parent.append(make('h3', text))
31
+ const fields = (parent, values) => {
32
+ const list = make('dl', null)
33
+ for (const [label, value] of values) append(list, make('dt', label), make('dd', value === undefined || value === null ? 'Not supplied' : String(value)))
34
+ parent.append(list)
35
+ }
36
+ const source = (parent, sourceRef, title = 'Exact source') => {
37
+ parent.append(make('h4', title))
38
+ if (!sourceRef) { paragraph(parent, 'Source pin not supplied.'); return }
39
+ fields(parent, [['Repository', sourceRef.repoId], ['Path', sourceRef.path], ['Revision', sourceRef.revision], ['Content digest', sourceRef.digest], ['Locator', sourceRef.locator ? locatorText(sourceRef.locator) : 'Whole source; no narrower locator supplied']])
40
+ }
41
+ const explicit = value => value === undefined || value === null ? 'Not supplied' : typeof value === 'string' ? value : JSON.stringify(value)
42
+ const button = (text, key, intent, pressed) => {
43
+ const control = make('button', text, { type: 'button', 'data-agv-key': key })
44
+ if (typeof pressed === 'boolean') control.setAttribute('aria-pressed', String(pressed))
45
+ control.addEventListener('click', () => emit(intent))
46
+ return control
47
+ }
48
+ const emit = async intent => {
49
+ if (disposed || !current) return
50
+ const digest = current.projectionDigest, revision = epoch, id = ++request
51
+ ui.status.textContent = 'Selection request sent. Awaiting host.'
52
+ try {
53
+ await onIntent(assertGraphViewIntent(current, { ...intent, projectionDigest: digest }))
54
+ if (!disposed && epoch === revision && id === request) ui.status.textContent = 'Request delivered.'
55
+ } catch {
56
+ if (!disposed && epoch === revision && id === request) ui.status.textContent = 'Request failed. Try again.'
57
+ }
58
+ }
59
+ const nodeFor = ref => current.nodes.find(node => sameRef(node.ref, ref))
60
+ const edgeFor = edgeId => current.edges.find(edge => edge.edgeId === edgeId)
61
+ const evidenceFor = id => current.evidence.find(item => item.evidenceId === id)
62
+ const evidencePin = (parent, evidenceId, title) => {
63
+ const item = evidenceFor(evidenceId)
64
+ if (!item) throw new TypeError('evidence pin unavailable')
65
+ source(parent, item.sourceRef, title)
66
+ fields(parent, [['Evidence identity', item.evidenceId], ['Support verification', item.verification]])
67
+ parent.append(button('Inspect evidence', `open:${title}:${evidenceId}`, { type: 'openEvidence', evidenceId }))
68
+ }
69
+ const labelFor = ref => nodeFor(ref)?.title ?? refText(ref)
70
+ const nodeButton = (ref, suffix) => button(labelFor(ref), `ref:${refKey(ref)}:${suffix}`, { type: 'select', ref: identity(ref) }, sameRef(selected?.ref, ref))
71
+ const shell = () => {
72
+ root.replaceChildren()
73
+ const style = make('style', structuredStyles)
74
+ ui = { host: make('div', null, { class: 'agv' }) }
75
+ append(root, style, ui.host)
76
+ append(ui.host, make('h2', 'Knowledge explorer'), make('a', 'Skip to evidence inspector', { class: 'agv-skip', href: `#${prefix}-inspector` }))
77
+ paragraph(ui.host, 'Search records, follow directed relationships, and inspect the exact evidence.')
78
+ ui.status = make('p', '', { class: 'agv-status', role: 'status', 'aria-live': 'polite', 'aria-atomic': 'true' })
79
+ ui.host.append(ui.status)
80
+ ui.content = make('div', null)
81
+ ui.host.append(ui.content)
82
+ }
83
+ const inspect = panel => {
84
+ if (!selected) { paragraph(panel, 'Choose a question, record, relationship, or evidence item.'); return }
85
+ if (selected.questionId) {
86
+ const question = current.questions.find(q => q.questionId === selected.questionId)
87
+ if (!question) return
88
+ panel.append(make('h4', question.title))
89
+ fields(panel, [['Decision', question.decision], ['Coverage', question.coverage], ['Omissions', question.omissionState]])
90
+ for (const gap of question.gaps ?? []) paragraph(panel, explicit(gap), 'agv-note')
91
+ if (question.coverage === 'abstain') paragraph(panel, 'The sources do not establish an answer.', 'agv-note')
92
+ if (question.nodeRefs?.length) { panel.append(make('h4', 'Related records')); for (const ref of question.nodeRefs) panel.append(nodeButton(ref, 'question')) }
93
+ for (const [label, refs] of [['Supporting evidence', question.evidenceRefs], ['Contradicting evidence', question.contradictingRefs]]) {
94
+ panel.append(make('h4', label))
95
+ if (!refs?.length) paragraph(panel, 'None supplied in this view.')
96
+ else for (const evidenceId of refs) panel.append(button(`Inspect ${evidenceFor(evidenceId)?.sourceRef.path ?? 'evidence'}`, `evidence:${evidenceId}`, { type: 'openEvidence', evidenceId }))
97
+ }
98
+ } else if (selected.edgeId) {
99
+ const edge = edgeFor(selected.edgeId)
100
+ if (!edge) return
101
+ panel.append(make('h4', `${labelFor(edge.source)} → ${edge.predicate} → ${labelFor(edge.target)}`))
102
+ fields(panel, [['Relationship identity', edge.edgeId], ['Direction', `${refText(edge.source)} → ${refText(edge.target)}`], ['Predicate', edge.predicate], ['Family', edge.family], ['Acceptance', edge.acceptanceState], ['Assurance', edge.assurance], ['Review receipts', edge.reviewRefs?.join(', ') || 'None supplied in this view'], ['Staleness', edge.staleness ?? (currentRefreshStatus?.stale ? 'Displayed source view is stale; edge currency not supplied' : 'Not supplied; do not assume current')], ['Verification checked at', edge.verification?.checkedAt], ['Record digest', edge.recordDigest], ['Policy digest', edge.policyDigest], ['Qualifications', edge.qualifications?.join('; ') || 'None supplied in this view']])
103
+ evidencePin(panel, edge.sourceRef, 'Relationship origin')
104
+ panel.append(make('h4', 'Support'))
105
+ if (!edge.supportRefs?.length) paragraph(panel, 'No support pins supplied.')
106
+ for (const id of edge.supportRefs ?? []) evidencePin(panel, id, 'Supporting source')
107
+ append(panel, nodeButton(edge.source, 'from'), nodeButton(edge.target, 'to'))
108
+ } else if (selected.evidenceId) {
109
+ const evidence = current.evidence.find(e => e.evidenceId === selected.evidenceId)
110
+ if (!evidence) return
111
+ panel.append(make('h4', evidence.title ?? evidence.evidenceId))
112
+ fields(panel, [['Evidence identity', evidence.evidenceId], ['Verification', explicit(evidence.verification)]])
113
+ source(panel, evidence.sourceRef)
114
+ } else if (selected.ref) {
115
+ const node = nodeFor(selected.ref)
116
+ if (!node) return
117
+ panel.append(make('h4', node.title))
118
+ if (node.summary) paragraph(panel, node.summary)
119
+ fields(panel, [['Record identity', refText(node.ref)], ['Source kind', node.sourceKind], ['Classification', node.classification], ['Lifecycle', node.lifecycle], ['Tags', node.tags?.join(', ') || 'None']])
120
+ source(panel, node.sourceRef)
121
+ const tools = make('div', null, { class: 'agv-tools' })
122
+ for (const [direction, label] of current.capabilities?.queries.includes('neighborhood') ? [['out', 'outgoing'], ['in', 'incoming'], ['both', 'both directions']] : []) tools.append(button(`Explore ${label}`, `expand:${direction}`, { type: 'expand', ref: identity(node.ref), direction }))
123
+ panel.append(tools)
124
+ const related = current.edges.filter(edge => sameRef(edge.source, node.ref) || sameRef(edge.target, node.ref))
125
+ panel.append(make('h4', 'Relationships in this view'))
126
+ if (!related.length) paragraph(panel, 'No relationships in this view.')
127
+ for (const edge of related) panel.append(button(`${labelFor(edge.source)} → ${edge.predicate} → ${labelFor(edge.target)}`, `inspect:${edge.edgeId}`, { type: 'select', edgeId: edge.edgeId }))
128
+ }
129
+ }
130
+ const render = ({ restore = true } = {}) => {
131
+ const active = root.contains(doc.activeElement) ? doc.activeElement : null
132
+ const focusKey = active?.getAttribute('data-agv-key')
133
+ const textRange = active?.tagName === 'INPUT' ? [active.selectionStart, active.selectionEnd] : null
134
+ ui.content.replaceChildren()
135
+ if (!current) { paragraph(ui.content, 'No graph view available.'); return }
136
+ const info = make('p', `Projection ${current.projectionDigest}`, { class: 'agv-ref' })
137
+ ui.content.append(info)
138
+ if (currentRefreshStatus?.stale) paragraph(ui.content, 'The displayed source view is stale. Rebuild the view before relying on it.', 'agv-note')
139
+ if (currentRefreshStatus?.failure) paragraph(ui.content, 'The view could not be refreshed. Inspect the source and retry through the host.', 'agv-note')
140
+ if (current.page?.truncated) {
141
+ const omitted = Object.entries(current.page.omitted ?? {}).filter(([, count]) => count > 0).map(([kind, count]) => `${count} ${kind}`).join(', ')
142
+ paragraph(ui.content, `This is a bounded view.${omitted ? ` Omitted from this view: ${omitted}.` : ''}`, 'agv-note')
143
+ if (current.page.nextCursor && typeof onPage === 'function') {
144
+ const more = make('button', 'Load next page', { type: 'button', 'data-agv-key': 'next-page' })
145
+ const pageRequest = Object.freeze({ projectionDigest: current.projectionDigest, cursor: current.page.nextCursor })
146
+ more.addEventListener('click', async () => {
147
+ if (disposed || !current || current.projectionDigest !== pageRequest.projectionDigest) return
148
+ const revision = epoch
149
+ try { await onPage(pageRequest) } catch { if (!disposed && epoch === revision) ui.status.textContent = 'Next page unavailable. Try again.' }
150
+ })
151
+ ui.content.append(more)
152
+ } else paragraph(ui.content, 'Ask the host for a broader view to inspect omitted results.')
153
+ }
154
+ const questions = make('section', null, { 'aria-label': 'Questions' })
155
+ heading(questions, 'Questions')
156
+ const qlist = make('ul', null, { class: 'agv-questions' })
157
+ for (const q of current.questions) qlist.append(append(make('li', null), button(`${q.title} · ${q.coverage}`, `question:${q.questionId}`, { type: 'setQuestion', questionId: q.questionId }, selected?.questionId === q.questionId)))
158
+ if (!current.questions.length) paragraph(questions, 'No questions in this view.')
159
+ questions.append(qlist); ui.content.append(questions)
160
+ const grid = make('div', null, { class: 'agv-grid' })
161
+ const records = make('section', null, { 'aria-label': 'Records' })
162
+ heading(records, 'Records')
163
+ const inputId = `${prefix}-search`, label = make('label', 'Search this view', { for: inputId })
164
+ const input = make('input', null, { id: inputId, type: 'search', 'data-agv-key': 'search', 'aria-describedby': `${prefix}-search-hint` })
165
+ input.value = query
166
+ const search = () => { query = input.value; const matches = render(); ui.status.textContent = `${matches} records in this view match.` }
167
+ input.addEventListener('input', event => { if (!event.isComposing) search() })
168
+ input.addEventListener('compositionend', search)
169
+ append(records, label, input, make('p', 'Search is limited to the records delivered in this view. Arrow keys move focus through the record list; Enter or Space selects.', { id: `${prefix}-search-hint` }))
170
+ const normalized = query.trim().toLocaleLowerCase()
171
+ const visible = current.nodes.filter(node => [node.title, node.summary, node.ref.repoId, node.ref.localId, ...(node.tags ?? [])].join(' ').toLocaleLowerCase().includes(normalized))
172
+ const count = make('p', `${visible.length} records in this view match.`)
173
+ records.append(count)
174
+ const list = make('ul', null, { class: 'agv-records', 'aria-label': 'Record search results' })
175
+ const buttons = []
176
+ if (!visible.some(node => refKey(node.ref) === roving)) roving = visible[0] ? refKey(visible[0].ref) : null
177
+ for (const node of visible) {
178
+ const control = nodeButton(node.ref, 'list')
179
+ control.tabIndex = refKey(node.ref) === roving ? 0 : -1
180
+ control.addEventListener('focus', () => { roving = refKey(node.ref); for (const b of buttons) b.tabIndex = b === control ? 0 : -1 })
181
+ control.append(make('small', `${node.sourceKind} · ${refText(node.ref)}`))
182
+ buttons.push(control); list.append(append(make('li', null), control))
183
+ }
184
+ list.addEventListener('keydown', event => {
185
+ const index = buttons.indexOf(doc.activeElement)
186
+ if (index < 0 || !['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return
187
+ event.preventDefault()
188
+ const next = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 : Math.max(0, Math.min(buttons.length - 1, index + (event.key === 'ArrowDown' ? 1 : -1)))
189
+ buttons[next]?.focus()
190
+ })
191
+ records.append(list)
192
+ if (!visible.length) paragraph(records, 'No matching records in this view.')
193
+ const panel = make('aside', null, { id: `${prefix}-inspector`, tabindex: '-1', 'aria-label': 'Evidence inspector' })
194
+ heading(panel, 'Evidence inspector'); inspect(panel)
195
+ append(grid, records, panel); ui.content.append(grid)
196
+ const relationships = make('section', null, { 'aria-label': 'Directed relationships' })
197
+ heading(relationships, 'Directed relationships')
198
+ const scroll = make('div', null, { class: 'agv-table', tabindex: '0', role: 'region', 'aria-label': 'Directed relationship table, scroll horizontally if needed' })
199
+ const table = make('table', null), head = make('thead', null), tr = make('tr', null), body = make('tbody', null)
200
+ table.append(make('caption', 'Direction runs from source, through predicate, to target.'))
201
+ for (const name of ['Source', 'Predicate', 'Target', 'Review and support']) tr.append(make('th', name, { scope: 'col' }))
202
+ head.append(tr); table.append(head)
203
+ for (const edge of current.edges) {
204
+ const row = make('tr', null)
205
+ append(row, append(make('td', null), nodeButton(edge.source, `table-source:${edge.edgeId}`)), append(make('td', null), button(edge.predicate, `edge:${edge.edgeId}`, { type: 'select', edgeId: edge.edgeId }, selected?.edgeId === edge.edgeId)), append(make('td', null), nodeButton(edge.target, `table-target:${edge.edgeId}`)), make('td', `${edge.assurance} · ${edge.acceptanceState} · ${edge.supportRefs?.length ?? 0} support pins · ${edge.reviewRefs?.length ?? 0} review receipts`))
206
+ body.append(row)
207
+ }
208
+ table.append(body); scroll.append(table); relationships.append(scroll)
209
+ if (!current.edges.length) paragraph(relationships, 'No directed relationships in this view.')
210
+ ui.content.append(relationships)
211
+ const diagnostics = make('section', null, { 'aria-label': 'Diagnostics' })
212
+ heading(diagnostics, 'Diagnostics')
213
+ for (const item of current.diagnostics ?? []) {
214
+ paragraph(diagnostics, `${item.severity}: ${item.code}. ${item.action}`, 'agv-note')
215
+ for (const ref of item.nodeRefs) diagnostics.append(nodeButton(ref, `diagnostic:${item.diagnosticId}`))
216
+ for (const evidenceId of item.evidenceRefs) diagnostics.append(button('Inspect diagnostic evidence', `diagnostic:${item.diagnosticId}:${evidenceId}`, { type: 'openEvidence', evidenceId }))
217
+ }
218
+ if (!current.diagnostics?.length) paragraph(diagnostics, 'No diagnostics supplied in this view.')
219
+ ui.content.append(diagnostics)
220
+ if (restore && focusKey) {
221
+ const replacement = [...root.querySelectorAll('[data-agv-key]')].find(element => element.getAttribute('data-agv-key') === focusKey)
222
+ if (replacement) { replacement.focus({ preventScroll: true }); if (textRange) replacement.setSelectionRange(...textRange) }
223
+ else panel.focus({ preventScroll: true })
224
+ }
225
+ return visible.length
226
+ }
227
+ const update = ({ view: nextView = null, selection: nextSelection = null, refreshStatus: nextRefreshStatus } = {}) => {
228
+ if (disposed) throw new TypeError('structured graph disposed')
229
+ ++epoch; ++request
230
+ const previousDigest = current?.projectionDigest, previousRefreshStatus = currentRefreshStatus
231
+ currentRefreshStatus = null
232
+ // Clear the prior bytes before accepting a replacement; never keep a stale
233
+ // authorized snapshot after the host withdraws or rejects delivery.
234
+ current = null; selected = null
235
+ if (nextView === null) { query = ''; roving = null; render(); ui.status.textContent = 'Graph view cleared.'; return }
236
+ try {
237
+ if (nextView.schema !== 'atelier.graph-view@v1' || !/^sha256:[a-f0-9]{64}$/.test(nextView.projectionDigest ?? '') || !Array.isArray(nextView.nodes) || !Array.isArray(nextView.edges) || !Array.isArray(nextView.questions) || !Array.isArray(nextView.evidence) || nextView.nodes.length > 500 || nextView.edges.length > 2000 || nextView.evidence.length > 4000 || nextView.questions.length > 100) throw new TypeError()
238
+ current = structuredClone(nextView)
239
+ currentRefreshStatus = nextRefreshStatus === undefined ? (previousDigest === current.projectionDigest ? previousRefreshStatus : null) : nextRefreshStatus === null ? null : { stale: nextRefreshStatus.stale === true, failure: Boolean(nextRefreshStatus.failure) }
240
+ if (nextSelection?.projectionDigest === current.projectionDigest) {
241
+ const keys = ['ref', 'edgeId', 'evidenceId', 'questionId'].filter(key => nextSelection[key] !== undefined)
242
+ const available = keys.length === 1 && (keys[0] === 'ref' ? nodeFor(nextSelection.ref) :
243
+ keys[0] === 'edgeId' ? edgeFor(nextSelection.edgeId) :
244
+ keys[0] === 'evidenceId' ? current.evidence.find(e => e.evidenceId === nextSelection.evidenceId) :
245
+ current.questions.find(q => q.questionId === nextSelection.questionId))
246
+ if (available) selected = structuredClone(nextSelection)
247
+ }
248
+ render()
249
+ ui.status.textContent = selected ? 'Selection updated.' : 'Graph view ready.'
250
+ } catch {
251
+ current = null; selected = null; currentRefreshStatus = null; query = ''; roving = null
252
+ render(); ui.status.textContent = 'Graph view unavailable.'
253
+ throw new TypeError('invalid structured graph view')
254
+ }
255
+ }
256
+ shell()
257
+ try { update({ view, selection, refreshStatus }) } catch (error) { mounts.delete(root); root.replaceChildren(); throw error }
258
+ return Object.freeze({ update, clear: () => update({ view: null }), destroy: () => {
259
+ if (disposed) return
260
+ disposed = true; ++epoch; ++request; current = null; selected = null; currentRefreshStatus = null; query = ''; root.replaceChildren(); mounts.delete(root)
261
+ } })
262
+ }
@@ -0,0 +1,8 @@
1
+ // Scope every rule to the mounted view; no global host styles or animation.
2
+ export const structuredStyles = `
3
+ .agv{color:#172b2a;background:#f6f8f5;font:1rem/1.55 system-ui,sans-serif;padding:clamp(.75rem,2vw,2rem);border:1px solid #657672;border-radius:.75rem;container-type:inline-size}
4
+ .agv *{box-sizing:border-box;min-width:0}.agv h2,.agv h3,.agv p{margin:0 0 .75rem}.agv h2{font-size:1.65rem;line-height:1.2}.agv h3{font-size:1.15rem}.agv h4{margin:1rem 0 .5rem}.agv button,.agv input,.agv select{font:inherit;color:inherit;background:#fff;border:1px solid #657672;border-radius:.35rem;min-height:2.75rem;padding:.5rem .75rem;max-width:100%}.agv button{cursor:pointer;text-align:start;overflow-wrap:anywhere}.agv button:disabled{cursor:default}.agv button[aria-pressed=true]{background:#d5e9e1;border:2px solid #154e40;font-weight:700}.agv :focus-visible{outline:3px solid #7029aa;outline-offset:3px}.agv label{display:block;font-weight:650;margin-bottom:.35rem}.agv input{width:100%}.agv section,.agv aside{background:#fff;padding:1rem;border:1px solid #b0bdb8;border-radius:.5rem;margin-block:1rem}.agv .agv-grid{display:grid;gap:1rem;grid-template-columns:minmax(0,1fr) minmax(0,1fr)}.agv .agv-grid section{margin:0}.agv ul{padding-inline-start:1.3rem}.agv .agv-records,.agv .agv-questions{list-style:none;padding:0;display:grid;gap:.5rem}.agv .agv-records button,.agv .agv-questions button{width:100%}.agv small{display:block;color:#405951}.agv .agv-tools{display:flex;flex-wrap:wrap;gap:.5rem;margin-block:.75rem}.agv .agv-table{overflow:auto;max-width:100%;border:1px solid #b0bdb8}.agv table{border-collapse:collapse;width:100%;min-width:32rem}.agv caption{text-align:start;font-weight:650;padding:.5rem}.agv th,.agv td{text-align:start;vertical-align:top;border-bottom:1px solid #b0bdb8;padding:.65rem;overflow-wrap:anywhere}.agv th{background:#edf3ef}.agv dt{font-weight:650}.agv dd{margin:0 0 .65rem;overflow-wrap:anywhere}.agv .agv-note{border-inline-start:4px solid #6b5132;padding-inline-start:.75rem}.agv .agv-status{min-height:1.55em}.agv .agv-ref{font-family:ui-monospace,monospace;font-size:.85em;overflow-wrap:anywhere}.agv .agv-skip{display:inline-block;padding:.5rem;min-height:2.75rem;color:inherit}.agv .agv-badge{font-weight:650}.agv [hidden]{display:none!important}
5
+ @container (max-width:48rem){.agv .agv-grid{grid-template-columns:minmax(0,1fr)}}
6
+ @media (prefers-reduced-motion:reduce){.agv,.agv *{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
7
+ @media (forced-colors:active){.agv,.agv section,.agv aside,.agv input,.agv button,.agv select{background:Canvas;color:CanvasText;border-color:CanvasText}.agv button[aria-pressed=true]{border:3px double Highlight}.agv small{color:CanvasText}.agv :focus-visible{outline-color:Highlight}}
8
+ `
@@ -0,0 +1,185 @@
1
+ // Focused authoring for concepts and plan relations: one step at a time, with
2
+ // structural guidance and a draft store behind an injected storage port.
3
+ //
4
+ // Guidance here is structural (empty, too short, name-only identity, example equals
5
+ // near miss). It is not the Node ontology guide and says nothing about truth.
6
+ // "ready-for-human-review" means complete and grounded in readable records; a person
7
+ // decides. The store writes only to the storage it is given: browser storage for a
8
+ // local draft, or a host port that records drafts durably for review.
9
+
10
+ const article = word => (/^[aeiou]/i.test(word) ? 'an ' : 'a ') + word.toLowerCase()
11
+
12
+ export function conceptSteps(concept) {
13
+ return [
14
+ { id: 'definition', label: 'Meaning', prompt: `What makes something ${article(concept.title)}?`, help: 'One sentence. Say what it is distinct from.' },
15
+ { id: 'identityRule', label: 'Same one?', prompt: 'How do you recognise the same one twice?', help: 'Similar names alone do not establish identity.' },
16
+ { id: 'example', label: 'Example', prompt: 'One concrete example from your own work.', help: 'A real case you could point to.' },
17
+ { id: 'nearMiss', label: 'Near miss', prompt: 'A close example that must stay separate.', help: 'The case people most often confuse with it.' },
18
+ { id: 'evidence', label: 'Evidence', prompt: 'Which records ground this distinction?', help: 'Cite records you have looked at. Citing does not make them members.', kind: 'evidence' },
19
+ { id: 'disconfirming', label: 'Prove wrong', prompt: 'What observation would show this distinction does not hold?', help: 'Name it now so you would notice it later.' },
20
+ ]
21
+ }
22
+
23
+ export function relationSteps(relation, titleOf = id => id) {
24
+ const verb = relation.predicate.replaceAll('_', ' ')
25
+ return [
26
+ { id: 'meaning', label: 'Meaning', prompt: `What does “${verb}” claim here?`, help: 'Say what the relation lets you check, not that it proves anything.' },
27
+ { id: 'forward', label: 'Forward', prompt: `Read it from ${titleOf(relation.from)} to ${titleOf(relation.to)}.`, help: `Start with “${titleOf(relation.from)}…”` },
28
+ { id: 'reverse', label: 'Reverse', prompt: `Read it from ${titleOf(relation.to)} back to ${titleOf(relation.from)}.`, help: 'The same claim from the other end. It creates no inverse relation.' },
29
+ { id: 'evidence', label: 'Evidence', prompt: 'Which records show this relation at work?', help: 'Cite records you have looked at. Citing does not create the relation.', kind: 'evidence' },
30
+ { id: 'disconfirming', label: 'Prove wrong', prompt: 'What would show this relation does not hold?', help: 'One observation is enough.' },
31
+ ]
32
+ }
33
+
34
+ const nameOnly = t => /\b(name|title|label|called)\b/i.test(t) && !/\b(not|alone|never|only by|unless)\b/i.test(t)
35
+ const fields = list => Object.fromEntries(list.map(k => [k, []]))
36
+ const summarize = f => { const all = Object.values(f).flat(); return { fields: f, errors: all.filter(m => m.tone === 'error').length, warnings: all.filter(m => m.tone === 'warn').length } }
37
+
38
+ export function guideConcept(concept, draft, readableIds = null) {
39
+ const f = fields(['definition', 'identityRule', 'example', 'nearMiss', 'evidence', 'disconfirming'])
40
+ const def = (draft.definition ?? '').trim()
41
+ if (!def) f.definition.push({ tone: 'error', text: 'Say what this is in one sentence.' })
42
+ else {
43
+ if (def.toLowerCase() === concept.title.toLowerCase()) f.definition.push({ tone: 'error', text: 'A definition should not just repeat the name.' })
44
+ if (def.length < 24) f.definition.push({ tone: 'warn', text: 'Add a boundary: what is it distinct from?' })
45
+ else if (!/\b(distinct|not|rather than|unlike|only|separate|excluding)\b/i.test(def)) f.definition.push({ tone: 'info', text: 'Naming what it is distinct from helps the next person apply it.' })
46
+ }
47
+ const rule = (draft.identityRule ?? '').trim()
48
+ if (!rule) f.identityRule.push({ tone: 'error', text: 'State how two records are recognised as the same one.' })
49
+ else if (nameOnly(rule)) f.identityRule.push({ tone: 'warn', text: 'Names alone do not establish identity. Say which event, place or id makes two the same.' })
50
+ if (!(draft.example ?? '').trim()) f.example.push({ tone: 'error', text: 'Add one concrete example.' })
51
+ const near = (draft.nearMiss ?? '').trim()
52
+ if (!near) f.nearMiss.push({ tone: 'error', text: 'Add a close example that must stay separate.' })
53
+ else if (near.toLowerCase() === (draft.example ?? '').trim().toLowerCase()) f.nearMiss.push({ tone: 'error', text: 'The near miss must differ from the example.' })
54
+ guideEvidence(f, draft, readableIds)
55
+ if (!(draft.disconfirming ?? '').trim()) f.disconfirming.push({ tone: 'error', text: 'Name one observation that would prove it wrong.' })
56
+ return summarize(f)
57
+ }
58
+
59
+ // Readiness promises completeness and grounding in readable records, so these block it.
60
+ function guideEvidence(f, draft, readableIds) {
61
+ const evidence = draft.evidence ?? []
62
+ if (!evidence.length) f.evidence.push({ tone: 'error', text: 'Cite at least one record you have looked at.' })
63
+ if (readableIds) for (const b of evidence) if (!readableIds.has(b.id)) f.evidence.push({ tone: 'error', text: 'One cited record is not readable here. It cannot support a proposal made from this view.' })
64
+ }
65
+
66
+ export function guideRelation(draft, readableIds = null) {
67
+ const f = fields(['meaning', 'forward', 'reverse', 'evidence', 'disconfirming'])
68
+ const meaning = (draft.meaning ?? '').trim()
69
+ if (!meaning) f.meaning.push({ tone: 'error', text: 'Explain what this relation claims.' })
70
+ else if (/\b(proves?|guarantees?|always)\b/i.test(meaning)) f.meaning.push({ tone: 'warn', text: 'Avoid claiming proof; say what it lets you check.' })
71
+ if (!(draft.forward ?? '').trim()) f.forward.push({ tone: 'error', text: 'Write the forward reading.' })
72
+ const reverse = (draft.reverse ?? '').trim()
73
+ if (!reverse) f.reverse.push({ tone: 'error', text: 'Write the reverse reading.' })
74
+ else if (reverse.toLowerCase() === (draft.forward ?? '').trim().toLowerCase()) f.reverse.push({ tone: 'warn', text: 'Start the reverse reading from the other end.' })
75
+ guideEvidence(f, draft, readableIds)
76
+ if (!(draft.disconfirming ?? '').trim()) f.disconfirming.push({ tone: 'error', text: 'Name what would show it does not hold.' })
77
+ return summarize(f)
78
+ }
79
+
80
+ /** answered · plan (unchanged plan wording) · attention · deferred · open */
81
+ export function stepState(value, guide = [], deferred = false, original) {
82
+ if (deferred) return 'deferred'
83
+ if (original !== undefined && typeof value === 'string' && value === original && value.trim()) return 'plan'
84
+ const answered = Array.isArray(value) ? value.length > 0 : !!value?.trim?.()
85
+ if (!answered) return 'open'
86
+ return guide.some(m => m.tone === 'error') ? 'attention' : 'answered'
87
+ }
88
+
89
+ /** Storage port shape: { getItem(key) → string|null, setItem(key, string), removeItem(key) }. */
90
+ export function memoryStorage() {
91
+ const map = new Map()
92
+ return { getItem: k => map.get(k) ?? null, setItem: (k, v) => { map.set(k, String(v)) }, removeItem: k => { map.delete(k) } }
93
+ }
94
+
95
+ // Identifier-indexed state uses null-prototype records and own-property lookup,
96
+ // so an id such as "constructor" or "__proto__" is just another key.
97
+ const dict = () => Object.create(null)
98
+ const own = (o, k) => Object.hasOwn(o, k) ? o[k] : undefined
99
+ const str = v => typeof v === 'string' && v.length <= 20000
100
+ const ID = v => typeof v === 'string' && v.length > 0 && v.length <= 512
101
+ const restoreEvidence = list => Array.isArray(list) ? list.filter(b => b && ID(b.id)).slice(0, 200).map(b => ({ id: b.id, title: str(b.title) ? b.title : b.id, citedAt: str(b.citedAt) ? b.citedAt : null })) : []
102
+ function restoreData(raw) {
103
+ const out = { concepts: dict(), relations: dict(), deferred: dict(), savedAt: null }
104
+ if (!raw || typeof raw !== 'object') return out
105
+ for (const [id, c] of Object.entries(raw.concepts ?? {})) {
106
+ if (!ID(id) || !c || typeof c !== 'object') continue
107
+ const d = { definition: '', identityRule: '', example: '', nearMiss: '', disconfirming: '', evidence: [] }
108
+ for (const k of ['definition', 'identityRule', 'example', 'nearMiss', 'disconfirming']) if (str(c[k])) d[k] = c[k]
109
+ d.evidence = restoreEvidence(c.evidence)
110
+ out.concepts[id] = d
111
+ }
112
+ for (const [id, r] of Object.entries(raw.relations ?? {})) {
113
+ if (!ID(id) || !r || typeof r !== 'object') continue
114
+ const d = { meaning: '', forward: '', reverse: '', disconfirming: '', evidence: [] }
115
+ for (const k of ['meaning', 'forward', 'reverse', 'disconfirming']) if (str(r[k])) d[k] = r[k]
116
+ d.evidence = restoreEvidence(r.evidence)
117
+ out.relations[id] = d
118
+ }
119
+ for (const [k, v] of Object.entries(raw.deferred ?? {})) if (ID(k) && v === true) out.deferred[k] = true
120
+ if (str(raw.savedAt)) out.savedAt = raw.savedAt
121
+ return out
122
+ }
123
+
124
+ /**
125
+ * Drafts behind a storage port. `identity` binds saved drafts to one workspace
126
+ * (and reader scope, where a host has one); a saved draft with another identity is
127
+ * never shown. `persistence` names where drafts live: 'tab', 'browser' or 'host'.
128
+ */
129
+ export function createDraftStore({ storage = memoryStorage(), key, identity, persistence = 'tab', delayMs = 600, now = () => new Date().toISOString() }) {
130
+ if (typeof key !== 'string' || !key) throw new TypeError('A draft key is required')
131
+ if (!identity || typeof identity !== 'object' || typeof identity.workspace !== 'string' || !identity.workspace) throw new TypeError('A draft identity with a workspace id is required')
132
+ if (!['tab', 'browser', 'host'].includes(persistence)) throw new TypeError('persistence must be tab, browser or host')
133
+ const identityKey = JSON.stringify(Object.keys(identity).sort().map(k => [k, identity[k]]))
134
+ let data = restoreData(null)
135
+ let status = 'empty'
136
+ try {
137
+ const raw = storage.getItem(key)
138
+ if (raw) {
139
+ const envelope = JSON.parse(raw)
140
+ if (envelope?.schema === 'atelier.graph-workspace-draft@v1' && envelope.identity === identityKey) { data = restoreData(envelope.data); status = 'restored' }
141
+ else status = 'empty'
142
+ }
143
+ } catch { status = 'unavailable' }
144
+ let timer = null
145
+ const listeners = new Set()
146
+ const emit = () => listeners.forEach(fn => fn(status))
147
+ function flush() {
148
+ clearTimeout(timer); timer = null
149
+ try { data.savedAt = now(); storage.setItem(key, JSON.stringify({ schema: 'atelier.graph-workspace-draft@v1', identity: identityKey, data })); status = 'saved' } catch { status = 'unavailable' }
150
+ emit()
151
+ }
152
+ return {
153
+ get data() { return data },
154
+ get status() { return status },
155
+ persistence,
156
+ onStatus(fn) { listeners.add(fn); return () => listeners.delete(fn) },
157
+ concept(c) { if (!own(data.concepts, c.id)) data.concepts[c.id] = { definition: c.definition ?? '', identityRule: c.identityRule ?? '', example: '', nearMiss: '', disconfirming: '', evidence: [] }; return data.concepts[c.id] },
158
+ relation(r) { if (!own(data.relations, r.id)) data.relations[r.id] = { meaning: r.meaning ?? '', forward: '', reverse: '', disconfirming: '', evidence: [] }; return data.relations[r.id] },
159
+ conceptDraft(id) { return own(data.concepts, id) },
160
+ relationDraft(id) { return own(data.relations, id) },
161
+ defer(kind, id, step, value) { const k = `${kind}:${id}:${step}`; if (value) data.deferred[k] = true; else delete data.deferred[k]; this.changed() },
162
+ deferred(kind, id, step) { return own(data.deferred, `${kind}:${id}:${step}`) === true },
163
+ changed() { status = 'saving'; emit(); clearTimeout(timer); if (delayMs <= 0) flush(); else timer = setTimeout(flush, delayMs) },
164
+ flush,
165
+ discard() { clearTimeout(timer); try { storage.removeItem(key) } catch { /* ignore */ } data = restoreData(null); status = 'empty'; emit() },
166
+ }
167
+ }
168
+
169
+ /** ready-for-human-review requires every step complete, evidence cited and every citation readable here. */
170
+ export function readiness(plan, store, readableIds) {
171
+ if (!(readableIds instanceof Set)) throw new TypeError('readiness needs the set of readable record ids')
172
+ const items = []
173
+ for (const c of plan?.concepts ?? []) {
174
+ const d = store.conceptDraft ? store.conceptDraft(c.id) : (Object.hasOwn(store.data.concepts, c.id) ? store.data.concepts[c.id] : undefined)
175
+ if (!d) continue
176
+ if (d.definition !== (c.definition ?? '') || d.identityRule !== (c.identityRule ?? '') || d.example || d.nearMiss || d.disconfirming || d.evidence?.length) items.push({ kind: 'concept', id: c.id, title: c.title, guide: guideConcept(c, d, readableIds) })
177
+ }
178
+ for (const r of plan?.relations ?? []) {
179
+ const d = store.relationDraft ? store.relationDraft(r.id) : (Object.hasOwn(store.data.relations, r.id) ? store.data.relations[r.id] : undefined)
180
+ if (!d) continue
181
+ if (d.meaning !== (r.meaning ?? '') || d.forward || d.reverse || d.disconfirming || d.evidence?.length) items.push({ kind: 'relation', id: r.id, title: r.id, guide: guideRelation(d, readableIds) })
182
+ }
183
+ const errors = items.reduce((n, i) => n + i.guide.errors, 0)
184
+ return { status: !items.length ? 'nothing-drafted' : errors ? 'needs-attention' : 'ready-for-human-review', items, errors }
185
+ }