@astrale-os/cli 0.4.0-alpha.13

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 (219) hide show
  1. package/.check-workspace.cjs +40 -0
  2. package/README.md +151 -0
  3. package/dist/astrale.js +59749 -0
  4. package/package.json +90 -0
  5. package/src/command.ts +40 -0
  6. package/src/commands/__tests__/admin-instance.test.ts +73 -0
  7. package/src/commands/__tests__/auth-login.test.ts +178 -0
  8. package/src/commands/__tests__/auth-token.test.ts +223 -0
  9. package/src/commands/__tests__/call.test.ts +72 -0
  10. package/src/commands/__tests__/domain-list.test.ts +74 -0
  11. package/src/commands/__tests__/help-contract.test.ts +136 -0
  12. package/src/commands/__tests__/install-identity-override.test.ts +65 -0
  13. package/src/commands/__tests__/instance-bookmark.test.ts +101 -0
  14. package/src/commands/__tests__/instance-create-hosts.test.ts +29 -0
  15. package/src/commands/__tests__/instance-list-rows.test.ts +63 -0
  16. package/src/commands/__tests__/logs.test.ts +117 -0
  17. package/src/commands/__tests__/ls.test.ts +25 -0
  18. package/src/commands/__tests__/setup-plan.test.ts +61 -0
  19. package/src/commands/admin/status.ts +61 -0
  20. package/src/commands/admin/use.ts +77 -0
  21. package/src/commands/auth/login.ts +82 -0
  22. package/src/commands/auth/logout.ts +50 -0
  23. package/src/commands/auth/status.ts +86 -0
  24. package/src/commands/auth/token.ts +162 -0
  25. package/src/commands/browser.ts +207 -0
  26. package/src/commands/call.ts +300 -0
  27. package/src/commands/describe.ts +182 -0
  28. package/src/commands/domain/install.ts +420 -0
  29. package/src/commands/domain/list.ts +154 -0
  30. package/src/commands/domain/publish.ts +155 -0
  31. package/src/commands/get.ts +60 -0
  32. package/src/commands/identity/create.ts +26 -0
  33. package/src/commands/identity/delete.ts +18 -0
  34. package/src/commands/identity/export.ts +66 -0
  35. package/src/commands/identity/import.ts +101 -0
  36. package/src/commands/identity/list.ts +56 -0
  37. package/src/commands/identity/register.ts +170 -0
  38. package/src/commands/identity/sync.ts +32 -0
  39. package/src/commands/identity/unsync.ts +24 -0
  40. package/src/commands/identity/use.ts +18 -0
  41. package/src/commands/identity/whoami.ts +34 -0
  42. package/src/commands/idp/add.ts +150 -0
  43. package/src/commands/idp/list.ts +57 -0
  44. package/src/commands/idp/refresh.ts +38 -0
  45. package/src/commands/idp/remove.ts +36 -0
  46. package/src/commands/idp/show.ts +29 -0
  47. package/src/commands/instance/active.ts +64 -0
  48. package/src/commands/instance/bookmark.ts +72 -0
  49. package/src/commands/instance/create.ts +69 -0
  50. package/src/commands/instance/delete.ts +72 -0
  51. package/src/commands/instance/forget.ts +26 -0
  52. package/src/commands/instance/list.ts +149 -0
  53. package/src/commands/instance/status.ts +42 -0
  54. package/src/commands/instance/use.ts +210 -0
  55. package/src/commands/logs.ts +347 -0
  56. package/src/commands/ls.ts +229 -0
  57. package/src/commands/query.ts +32 -0
  58. package/src/commands/setup.ts +54 -0
  59. package/src/commands/status.ts +60 -0
  60. package/src/commands/studio.ts +401 -0
  61. package/src/commands/token.ts +77 -0
  62. package/src/commands/update.ts +267 -0
  63. package/src/commands/use.ts +87 -0
  64. package/src/errors.ts +65 -0
  65. package/src/kernel/__tests__/auth.test.ts +77 -0
  66. package/src/kernel/__tests__/errors.test.ts +43 -0
  67. package/src/kernel/__tests__/remote-routing.test.ts +70 -0
  68. package/src/kernel/auth.ts +234 -0
  69. package/src/kernel/ca-fetch.ts +119 -0
  70. package/src/kernel/client.ts +191 -0
  71. package/src/kernel/errors.ts +280 -0
  72. package/src/kernel/expand.ts +217 -0
  73. package/src/kernel/index.ts +14 -0
  74. package/src/kernel/options.ts +22 -0
  75. package/src/kernel/remote-routing.ts +88 -0
  76. package/src/kernel/run.ts +63 -0
  77. package/src/kernel/types.ts +14 -0
  78. package/src/lib/__tests__/admin-target.test.ts +112 -0
  79. package/src/lib/__tests__/binary.test.ts +56 -0
  80. package/src/lib/__tests__/command-dx.test.ts +58 -0
  81. package/src/lib/__tests__/concurrency.test.ts +62 -0
  82. package/src/lib/__tests__/config.test.ts +53 -0
  83. package/src/lib/__tests__/design.test.ts +99 -0
  84. package/src/lib/__tests__/domain-identity.test.ts +60 -0
  85. package/src/lib/__tests__/format.test.ts +22 -0
  86. package/src/lib/__tests__/fs-atomic.test.ts +104 -0
  87. package/src/lib/__tests__/identity.test.ts +79 -0
  88. package/src/lib/__tests__/idp-session.driver.ts +53 -0
  89. package/src/lib/__tests__/idp-session.test.ts +357 -0
  90. package/src/lib/__tests__/idp.test.ts +385 -0
  91. package/src/lib/__tests__/instance-candidates.test.ts +73 -0
  92. package/src/lib/__tests__/instance-target.test.ts +183 -0
  93. package/src/lib/__tests__/instance.test.ts +136 -0
  94. package/src/lib/__tests__/keys.test.ts +129 -0
  95. package/src/lib/__tests__/local-status.test.ts +202 -0
  96. package/src/lib/__tests__/output.test.ts +150 -0
  97. package/src/lib/__tests__/panel.test.ts +40 -0
  98. package/src/lib/__tests__/port.test.ts +44 -0
  99. package/src/lib/__tests__/prompt.test.ts +25 -0
  100. package/src/lib/__tests__/sdk-deps.test.ts +68 -0
  101. package/src/lib/__tests__/self.test.ts +272 -0
  102. package/src/lib/__tests__/studio-server-deps.test.ts +74 -0
  103. package/src/lib/__tests__/table.test.ts +53 -0
  104. package/src/lib/__tests__/update.test.ts +246 -0
  105. package/src/lib/__tests__/use-target.test.ts +56 -0
  106. package/src/lib/__tests__/validation.test.ts +34 -0
  107. package/src/lib/admin-domain.ts +25 -0
  108. package/src/lib/admin-instance.ts +26 -0
  109. package/src/lib/admin-target.ts +217 -0
  110. package/src/lib/binary.ts +131 -0
  111. package/src/lib/browser.ts +150 -0
  112. package/src/lib/command-dx.ts +161 -0
  113. package/src/lib/concurrency.ts +31 -0
  114. package/src/lib/config.ts +45 -0
  115. package/src/lib/domain-identity.ts +49 -0
  116. package/src/lib/env.ts +49 -0
  117. package/src/lib/format.ts +4 -0
  118. package/src/lib/fs-atomic.ts +126 -0
  119. package/src/lib/identity.ts +256 -0
  120. package/src/lib/idp-session.ts +134 -0
  121. package/src/lib/idp.ts +876 -0
  122. package/src/lib/instance-candidates.ts +49 -0
  123. package/src/lib/instance-target.ts +182 -0
  124. package/src/lib/instance.ts +395 -0
  125. package/src/lib/keys.ts +294 -0
  126. package/src/lib/local-status.ts +152 -0
  127. package/src/lib/log.ts +116 -0
  128. package/src/lib/login-flow.ts +164 -0
  129. package/src/lib/meta.ts +86 -0
  130. package/src/lib/output.ts +222 -0
  131. package/src/lib/panel.ts +61 -0
  132. package/src/lib/paths.ts +11 -0
  133. package/src/lib/port.ts +41 -0
  134. package/src/lib/proc.ts +82 -0
  135. package/src/lib/prompt.ts +136 -0
  136. package/src/lib/provision-instance.ts +170 -0
  137. package/src/lib/sdk-deps.ts +104 -0
  138. package/src/lib/self.ts +166 -0
  139. package/src/lib/skills.ts +171 -0
  140. package/src/lib/table.ts +62 -0
  141. package/src/lib/update.ts +315 -0
  142. package/src/lib/use-target.ts +24 -0
  143. package/src/lib/validation.ts +59 -0
  144. package/src/program.ts +200 -0
  145. package/src/registry.ts +59 -0
  146. package/src/setup/__tests__/util.test.ts +29 -0
  147. package/src/setup/engine.ts +83 -0
  148. package/src/setup/render.ts +109 -0
  149. package/src/setup/steps/admin.ts +78 -0
  150. package/src/setup/steps/agent-browser.ts +81 -0
  151. package/src/setup/steps/auth.ts +54 -0
  152. package/src/setup/steps/domain.ts +68 -0
  153. package/src/setup/steps/index.ts +18 -0
  154. package/src/setup/steps/instance.ts +119 -0
  155. package/src/setup/steps/skills-bridge.ts +70 -0
  156. package/src/setup/steps/skills.ts +59 -0
  157. package/src/setup/types.ts +61 -0
  158. package/src/setup/util.ts +34 -0
  159. package/src/test-utils.ts +18 -0
  160. package/studio/client/dist/assets/index-DOwzZAEK.css +1 -0
  161. package/studio/client/dist/assets/index-wtU0Zxhy.js +183 -0
  162. package/studio/client/dist/index.html +13 -0
  163. package/studio/package.json +62 -0
  164. package/studio/server/agent/ask.ts +68 -0
  165. package/studio/server/agent/bridge-mcp.ts +182 -0
  166. package/studio/server/agent/bridge.ts +188 -0
  167. package/studio/server/agent/claude.ts +666 -0
  168. package/studio/server/agent/mock.ts +186 -0
  169. package/studio/server/agent/prompt.ts +202 -0
  170. package/studio/server/agent/registry.ts +29 -0
  171. package/studio/server/agent/runner.ts +484 -0
  172. package/studio/server/agent/schema-map.ts +112 -0
  173. package/studio/server/agent/types.ts +120 -0
  174. package/studio/server/api.ts +574 -0
  175. package/studio/server/cache.ts +138 -0
  176. package/studio/server/detect.ts +81 -0
  177. package/studio/server/domain.ts +70 -0
  178. package/studio/server/index.ts +136 -0
  179. package/studio/server/introspect/anatomy-extras.ts +398 -0
  180. package/studio/server/introspect/anatomy.ts +108 -0
  181. package/studio/server/introspect/bundle.ts +57 -0
  182. package/studio/server/introspect/core-extractor.ts +119 -0
  183. package/studio/server/introspect/core.ts +44 -0
  184. package/studio/server/introspect/diff.ts +133 -0
  185. package/studio/server/introspect/extractor.ts +102 -0
  186. package/studio/server/introspect/hash.ts +21 -0
  187. package/studio/server/introspect/overlay-tsmorph.ts +874 -0
  188. package/studio/server/introspect/overlay.ts +57 -0
  189. package/studio/server/introspect/runtime.ts +99 -0
  190. package/studio/server/introspect/schema-refs.ts +46 -0
  191. package/studio/server/lifecycle.ts +38 -0
  192. package/studio/server/sse.ts +57 -0
  193. package/studio/server/state/baseline.ts +211 -0
  194. package/studio/server/state/catalog.ts +117 -0
  195. package/studio/server/state/comments.ts +321 -0
  196. package/studio/server/state/context.ts +167 -0
  197. package/studio/server/state/copy.ts +156 -0
  198. package/studio/server/state/create.ts +156 -0
  199. package/studio/server/state/documents.ts +70 -0
  200. package/studio/server/state/env.ts +161 -0
  201. package/studio/server/state/git.ts +75 -0
  202. package/studio/server/state/handoff.ts +55 -0
  203. package/studio/server/state/harness-gateway.ts +181 -0
  204. package/studio/server/state/harness-token.ts +0 -0
  205. package/studio/server/state/instance.ts +244 -0
  206. package/studio/server/state/integrations.ts +55 -0
  207. package/studio/server/state/layout.ts +55 -0
  208. package/studio/server/state/settings.ts +39 -0
  209. package/studio/server/state/store.ts +97 -0
  210. package/studio/server/state/updates.ts +63 -0
  211. package/studio/server/state/usage.ts +37 -0
  212. package/studio/server/state/views.ts +138 -0
  213. package/studio/server/state/visibility.ts +33 -0
  214. package/studio/server/watch.ts +81 -0
  215. package/studio/server/workspace-state.ts +26 -0
  216. package/studio/server/workspace-watch.ts +101 -0
  217. package/studio/shared/types.ts +873 -0
  218. package/studio/tsconfig.json +23 -0
  219. package/tsconfig.json +14 -0
@@ -0,0 +1,321 @@
1
+ /**
2
+ * comments.ts — the comments / open-questions store. ALL persistence goes through
3
+ * store.ts (writes are allow-listed to <domain>/.domain-studio/). The on-disk file
4
+ * is `comments.json`, shaped { schemaVersion, comments: Comment[] }.
5
+ *
6
+ * Comments are annotate-compatible: each thread entry is
7
+ * { id, role:'user'|'author', type:'text'|'choice', text, options?, answer? }
8
+ * and `kind` is derived from the FIRST thread entry's role
9
+ * (author-seeded ⇒ 'question', else 'comment').
10
+ */
11
+ import type { Comment, CommentStore, ThreadEntry } from '../../shared/types'
12
+
13
+ import { readJson, writeJson } from './store'
14
+
15
+ const FILE = 'comments.json'
16
+
17
+ const EMPTY: CommentStore = { schemaVersion: '', comments: [] }
18
+
19
+ export function readComments(root: string): CommentStore {
20
+ const store = readJson<CommentStore>(root, FILE, { ...EMPTY })
21
+ return {
22
+ schemaVersion: store.schemaVersion ?? '',
23
+ comments: Array.isArray(store.comments) ? store.comments : [],
24
+ }
25
+ }
26
+
27
+ function persist(root: string, store: CommentStore): void {
28
+ writeJson(root, FILE, store)
29
+ }
30
+
31
+ /** Derive `kind` from the first thread entry's role. */
32
+ function deriveKind(thread: ThreadEntry[]): Comment['kind'] {
33
+ return thread[0]?.role === 'author' ? 'question' : 'comment'
34
+ }
35
+
36
+ function makeEntry(input: {
37
+ role: ThreadEntry['role']
38
+ type?: ThreadEntry['type']
39
+ text: string
40
+ options?: string[]
41
+ answer?: string | null
42
+ }): ThreadEntry {
43
+ const entry: ThreadEntry = {
44
+ id: crypto.randomUUID(),
45
+ role: input.role,
46
+ type: input.type ?? 'text',
47
+ text: input.text,
48
+ }
49
+ if (input.options !== undefined) entry.options = input.options
50
+ if (input.answer !== undefined) entry.answer = input.answer
51
+ return entry
52
+ }
53
+
54
+ export function upsertComment(
55
+ root: string,
56
+ input: {
57
+ id?: string
58
+ anchors: string[]
59
+ anchorRefs: import('../../shared/types').AnchorRef[]
60
+ text?: string
61
+ firstRole?: 'user' | 'author'
62
+ type?: 'text' | 'choice'
63
+ options?: string[]
64
+ schemaVersion?: string
65
+ },
66
+ ): Comment {
67
+ const store = readComments(root)
68
+ if (input.schemaVersion !== undefined) store.schemaVersion = input.schemaVersion
69
+
70
+ // If id provided and exists → append a thread entry instead of creating.
71
+ if (input.id) {
72
+ const existing = store.comments.find((c) => c.id === input.id)
73
+ if (existing) {
74
+ const entry = makeEntry({
75
+ role: input.firstRole ?? 'user',
76
+ type: input.type,
77
+ text: input.text ?? '',
78
+ options: input.options,
79
+ })
80
+ existing.thread.push(entry)
81
+ existing.kind = deriveKind(existing.thread)
82
+ persist(root, store)
83
+ return existing
84
+ }
85
+ }
86
+
87
+ const role = input.firstRole ?? 'user'
88
+ const firstEntry = makeEntry({
89
+ role,
90
+ type: input.type,
91
+ text: input.text ?? '',
92
+ options: input.options,
93
+ })
94
+ const comment: Comment = {
95
+ id: input.id ?? crypto.randomUUID(),
96
+ anchors: input.anchors,
97
+ anchorRefs: input.anchorRefs,
98
+ status: 'open',
99
+ thread: [firstEntry],
100
+ createdAt: new Date().toISOString(),
101
+ kind: role === 'author' ? 'question' : 'comment',
102
+ }
103
+ store.comments.push(comment)
104
+ persist(root, store)
105
+ return comment
106
+ }
107
+
108
+ export function addThreadEntry(
109
+ root: string,
110
+ id: string,
111
+ entry: {
112
+ role: 'user' | 'author'
113
+ type?: 'text' | 'choice'
114
+ text: string
115
+ options?: string[]
116
+ answer?: string | null
117
+ },
118
+ ): Comment | null {
119
+ const store = readComments(root)
120
+ const comment = store.comments.find((c) => c.id === id)
121
+ if (!comment) return null
122
+ comment.thread.push(makeEntry(entry))
123
+ comment.kind = deriveKind(comment.thread)
124
+ persist(root, store)
125
+ return comment
126
+ }
127
+
128
+ /** Edit the text of a single thread entry in place (the studio is single-user). */
129
+ export function editThreadEntry(
130
+ root: string,
131
+ id: string,
132
+ entryId: string,
133
+ text: string,
134
+ ): Comment | null {
135
+ const store = readComments(root)
136
+ const comment = store.comments.find((c) => c.id === id)
137
+ if (!comment) return null
138
+ const entry = comment.thread.find((e) => e.id === entryId)
139
+ if (!entry) return null
140
+ entry.text = text
141
+ persist(root, store)
142
+ return comment
143
+ }
144
+
145
+ export function setStatus(
146
+ root: string,
147
+ id: string,
148
+ status: 'open' | 'closed',
149
+ closeNote?: string,
150
+ ): Comment | null {
151
+ const store = readComments(root)
152
+ const comment = store.comments.find((c) => c.id === id)
153
+ if (!comment) return null
154
+ comment.status = status
155
+ if (closeNote !== undefined) comment.closeNote = closeNote
156
+ comment.kind = deriveKind(comment.thread)
157
+ persist(root, store)
158
+ return comment
159
+ }
160
+
161
+ export function deleteComment(root: string, id: string): boolean {
162
+ const store = readComments(root)
163
+ const idx = store.comments.findIndex((c) => c.id === id)
164
+ if (idx === -1) return false
165
+ store.comments.splice(idx, 1)
166
+ persist(root, store)
167
+ return true
168
+ }
169
+
170
+ /**
171
+ * Mark a comment orphaned when NONE of its anchorRefs[].ref is present in
172
+ * `validRefs`; clear the flag otherwise. Persists and returns the store.
173
+ */
174
+ export function markOrphans(root: string, validRefs: string[]): CommentStore {
175
+ const valid = new Set(validRefs)
176
+ const store = readComments(root)
177
+ for (const comment of store.comments) {
178
+ const refs = comment.anchorRefs ?? []
179
+ const anyValid = refs.some((r) => valid.has(r.ref))
180
+ comment.orphaned = refs.length > 0 ? !anyValid : true
181
+ comment.kind = deriveKind(comment.thread)
182
+ }
183
+ persist(root, store)
184
+ return store
185
+ }
186
+
187
+ interface PastedThreadEntry {
188
+ id?: string
189
+ role?: 'user' | 'author'
190
+ type?: 'text' | 'choice'
191
+ text?: string
192
+ options?: string[]
193
+ answer?: string | null
194
+ }
195
+
196
+ interface PastedComment {
197
+ id?: string
198
+ anchors?: string[]
199
+ status?: 'open' | 'closed'
200
+ closeNote?: string
201
+ thread?: PastedThreadEntry[]
202
+ }
203
+
204
+ interface PastedStore {
205
+ schemaVersion?: string
206
+ comments?: PastedComment[]
207
+ }
208
+
209
+ /** Extract the LAST fenced ```json … ``` block from arbitrary prose. */
210
+ function extractLastJsonBlock(text: string): string | null {
211
+ const fence = /```json\s*\n?([\s\S]*?)```/gi
212
+ let match: RegExpExecArray | null
213
+ let last: string | null = null
214
+ while ((match = fence.exec(text)) !== null) {
215
+ last = match[1]
216
+ }
217
+ return last == null ? null : last.trim()
218
+ }
219
+
220
+ function normalizePastedEntry(e: PastedThreadEntry): ThreadEntry {
221
+ const entry: ThreadEntry = {
222
+ id: e.id && e.id.length > 0 ? e.id : crypto.randomUUID(),
223
+ role: e.role === 'author' ? 'author' : 'user',
224
+ type: e.type === 'choice' ? 'choice' : 'text',
225
+ text: e.text ?? '',
226
+ }
227
+ if (e.options !== undefined) entry.options = e.options
228
+ if (e.answer !== undefined) entry.answer = e.answer
229
+ return entry
230
+ }
231
+
232
+ /**
233
+ * Merge an agent-pasted machine-state reply (annotate shape) into the store.
234
+ * - LAST ```json``` block is parsed.
235
+ * - For each pasted comment by id: if known locally, append NEW thread entries
236
+ * (dedupe by entry id) and apply status/closeNote; if unknown → unknownIds.
237
+ * - schemaMismatch when parsed.schemaVersion differs from currentSchemaHash.
238
+ */
239
+ export function mergeReply(
240
+ root: string,
241
+ currentSchemaHash: string,
242
+ pastedText: string,
243
+ opts?: {
244
+ /** live loop: skip ONLY the texts already applied live this run, per comment id */
245
+ skipByComment?: Map<string, Set<string>>
246
+ /** manual paste: dedupe author replies against the whole thread (a re-paste with
247
+ * regenerated ids would otherwise duplicate) — there are no live replies to scope to */
248
+ dedupeAuthorText?: boolean
249
+ },
250
+ ): import('../../shared/types').MergeResult {
251
+ const block = extractLastJsonBlock(pastedText)
252
+ if (block == null) {
253
+ throw new Error('no machine-state json block found in pasted text')
254
+ }
255
+ const parsed = JSON.parse(block) as PastedStore
256
+ const pastedComments = Array.isArray(parsed.comments) ? parsed.comments : []
257
+
258
+ const store = readComments(root)
259
+ let merged = 0
260
+ let closed = 0
261
+ const unknownIds: string[] = []
262
+
263
+ for (const pc of pastedComments) {
264
+ if (!pc.id) {
265
+ unknownIds.push('')
266
+ continue
267
+ }
268
+ const local = store.comments.find((c) => c.id === pc.id)
269
+ if (!local) {
270
+ unknownIds.push(pc.id)
271
+ continue
272
+ }
273
+
274
+ const seen = new Set(local.thread.map((t) => t.id))
275
+ // Skip ONLY the author texts already applied LIVE this run (the agent regenerates
276
+ // ids, so id-dedupe alone wouldn't catch a bridge reply echoed in the block). Scope
277
+ // it to this run — NOT the whole thread history — so a legitimately-repeated reply
278
+ // across turns ("Done." twice) is never dropped.
279
+ const liveTexts = opts?.skipByComment?.get(pc.id)
280
+ const authorTexts = opts?.dedupeAuthorText
281
+ ? new Set(local.thread.filter((t) => t.role === 'author').map((t) => t.text.trim()))
282
+ : null
283
+ let appended = 0
284
+ for (const pe of pc.thread ?? []) {
285
+ const entry = normalizePastedEntry(pe)
286
+ if (seen.has(entry.id)) continue
287
+ if (
288
+ entry.role === 'author' &&
289
+ (liveTexts?.has(entry.text.trim()) || authorTexts?.has(entry.text.trim()))
290
+ )
291
+ continue
292
+ seen.add(entry.id)
293
+ if (entry.role === 'author') authorTexts?.add(entry.text.trim())
294
+ local.thread.push(entry)
295
+ appended += 1
296
+ }
297
+ if (appended > 0) merged += 1
298
+
299
+ // Only ever close from a merge — never REOPEN a thread the user closed mid-run.
300
+ if (pc.status === 'closed' && local.status !== 'closed') {
301
+ local.status = 'closed'
302
+ closed += 1
303
+ }
304
+ if (pc.closeNote !== undefined) local.closeNote = pc.closeNote
305
+
306
+ local.kind = deriveKind(local.thread)
307
+ }
308
+
309
+ persist(root, store)
310
+
311
+ const pastedSchemaVersion = parsed.schemaVersion
312
+ const schemaMismatch = Boolean(pastedSchemaVersion && pastedSchemaVersion !== currentSchemaHash)
313
+
314
+ return {
315
+ merged,
316
+ closed,
317
+ unknownIds,
318
+ schemaMismatch,
319
+ pastedSchemaVersion,
320
+ }
321
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * context.ts — persists studio context items split into two buckets:
3
+ * - user items at context/user/index.json (ContextItem[])
4
+ * - auto items at context/auto/index.json (ContextItem[])
5
+ * Auto items are also mirrored to readable .md files under context/auto/ so an
6
+ * external agent can read them from disk. ALL writes go through store.ts.
7
+ */
8
+ import type { ContextItem, ContextStore } from '../../shared/types'
9
+
10
+ import { readJson, writeJson, writeState } from './store'
11
+
12
+ const USER_PATH = 'context/user/index.json'
13
+ const AUTO_PATH = 'context/auto/index.json'
14
+
15
+ function readUser(root: string): ContextItem[] {
16
+ return readJson<ContextItem[]>(root, USER_PATH, [])
17
+ }
18
+
19
+ function readAuto(root: string): ContextItem[] {
20
+ return readJson<ContextItem[]>(root, AUTO_PATH, [])
21
+ }
22
+
23
+ function writeUser(root: string, items: ContextItem[]): void {
24
+ writeJson(root, USER_PATH, items)
25
+ }
26
+
27
+ function writeAuto(root: string, items: ContextItem[]): void {
28
+ writeJson(root, AUTO_PATH, items)
29
+ }
30
+
31
+ function now(): string {
32
+ return new Date().toISOString()
33
+ }
34
+
35
+ export function readContext(root: string): ContextStore {
36
+ return { user: readUser(root), auto: readAuto(root) }
37
+ }
38
+
39
+ export function addUserContext(
40
+ root: string,
41
+ input: { title: string; body: string; source?: string },
42
+ ): ContextItem {
43
+ const item: ContextItem = {
44
+ id: crypto.randomUUID(),
45
+ bucket: 'user',
46
+ title: input.title,
47
+ body: input.body,
48
+ source: input.source,
49
+ updatedAt: now(),
50
+ }
51
+ const items = readUser(root)
52
+ items.push(item)
53
+ writeUser(root, items)
54
+ return item
55
+ }
56
+
57
+ export function updateContext(
58
+ root: string,
59
+ id: string,
60
+ patch: { title?: string; body?: string },
61
+ ): ContextItem | null {
62
+ const user = readUser(root)
63
+ const ui = user.findIndex((it) => it.id === id)
64
+ if (ui !== -1) {
65
+ const updated: ContextItem = {
66
+ ...user[ui],
67
+ ...(patch.title !== undefined ? { title: patch.title } : {}),
68
+ ...(patch.body !== undefined ? { body: patch.body } : {}),
69
+ updatedAt: now(),
70
+ }
71
+ user[ui] = updated
72
+ writeUser(root, user)
73
+ return updated
74
+ }
75
+ const auto = readAuto(root)
76
+ const ai = auto.findIndex((it) => it.id === id)
77
+ if (ai !== -1) {
78
+ const updated: ContextItem = {
79
+ ...auto[ai],
80
+ ...(patch.title !== undefined ? { title: patch.title } : {}),
81
+ ...(patch.body !== undefined ? { body: patch.body } : {}),
82
+ updatedAt: now(),
83
+ }
84
+ auto[ai] = updated
85
+ writeAuto(root, auto)
86
+ return updated
87
+ }
88
+ return null
89
+ }
90
+
91
+ export function deleteContext(root: string, id: string): boolean {
92
+ const user = readUser(root)
93
+ const userKept = user.filter((it) => it.id !== id)
94
+ if (userKept.length !== user.length) {
95
+ writeUser(root, userKept)
96
+ return true
97
+ }
98
+ const auto = readAuto(root)
99
+ const autoKept = auto.filter((it) => it.id !== id)
100
+ if (autoKept.length !== auto.length) {
101
+ writeAuto(root, autoKept)
102
+ return true
103
+ }
104
+ return false
105
+ }
106
+
107
+ export function setAutoInclude(root: string, id: string, include: boolean): ContextItem | null {
108
+ const auto = readAuto(root)
109
+ const idx = auto.findIndex((it) => it.id === id)
110
+ if (idx === -1) return null
111
+ const updated: ContextItem = {
112
+ ...auto[idx],
113
+ includeInHandoff: include,
114
+ updatedAt: now(),
115
+ }
116
+ auto[idx] = updated
117
+ writeAuto(root, auto)
118
+ return updated
119
+ }
120
+
121
+ interface AutoSpec {
122
+ title: string
123
+ body: string | undefined
124
+ file: string
125
+ }
126
+
127
+ export function materializeAuto(
128
+ root: string,
129
+ digests: { changes?: string; schemaChange?: string; comments?: string },
130
+ ): void {
131
+ const specs: AutoSpec[] = [
132
+ { title: 'Current changes', body: digests.changes, file: 'changes.md' },
133
+ { title: 'Schema changes', body: digests.schemaChange, file: 'schema-change.md' },
134
+ { title: 'Open questions digest', body: digests.comments, file: 'comments-digest.md' },
135
+ ]
136
+
137
+ const auto = readAuto(root)
138
+ const ts = now()
139
+
140
+ for (const spec of specs) {
141
+ if (spec.body === undefined) continue
142
+ const existing = auto.find((it) => it.title === spec.title)
143
+ if (existing) {
144
+ existing.body = spec.body
145
+ existing.bucket = 'auto'
146
+ existing.source = 'auto-computed'
147
+ existing.freshness = ts
148
+ existing.updatedAt = ts
149
+ // preserve prior include choice
150
+ if (existing.includeInHandoff === undefined) existing.includeInHandoff = false
151
+ } else {
152
+ auto.push({
153
+ id: crypto.randomUUID(),
154
+ bucket: 'auto',
155
+ title: spec.title,
156
+ body: spec.body,
157
+ source: 'auto-computed',
158
+ updatedAt: ts,
159
+ freshness: ts,
160
+ includeInHandoff: false,
161
+ })
162
+ }
163
+ writeState(root, `context/auto/${spec.file}`, spec.body)
164
+ }
165
+
166
+ writeAuto(root, auto)
167
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * copy.ts — the copy-payload builder. A PURE function (no IO): it turns studio
3
+ * state into a single Markdown handoff payload that an author agent can paste
4
+ * into its own session. The payload ends with a fenced ```json``` machine-state
5
+ * block in the annotate shape so the author can reply by appending
6
+ * {role:'author'} thread entries and merging back by id.
7
+ */
8
+ import type { Comment, ContextItem, DocMeta, ThreadEntry } from '../../shared/types'
9
+
10
+ interface CopyParts {
11
+ origin: string
12
+ root: string
13
+ schemaHash: string
14
+ openComments: Comment[]
15
+ userContext: ContextItem[]
16
+ autoContext: ContextItem[]
17
+ documents?: DocMeta[]
18
+ }
19
+
20
+ function primaryAnchor(comment: Comment): { ref: string; file?: string } | null {
21
+ const ref = comment.anchorRefs?.[0]
22
+ if (!ref) return null
23
+ return { ref: ref.ref, file: ref.file }
24
+ }
25
+
26
+ function latestThreadEntry(comment: Comment): ThreadEntry | null {
27
+ const thread = comment.thread ?? []
28
+ return thread.length > 0 ? thread[thread.length - 1] : null
29
+ }
30
+
31
+ function formatBytes(n: number): string {
32
+ if (!Number.isFinite(n) || n < 0) return 'unknown size'
33
+ if (n < 1024) return `${n} B`
34
+ if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`
35
+ return `${(n / 1024 / 1024).toFixed(1)} MB`
36
+ }
37
+
38
+ /** annotate machine-state projection of a comment: {id, anchors, status, thread}. */
39
+ function toMachineState(comment: Comment): {
40
+ id: string
41
+ anchors: string[]
42
+ status: 'open' | 'closed'
43
+ thread: ThreadEntry[]
44
+ } {
45
+ return {
46
+ id: comment.id,
47
+ anchors: comment.anchors ?? [],
48
+ status: comment.status,
49
+ thread: (comment.thread ?? []).map((t) => {
50
+ const entry: ThreadEntry = {
51
+ id: t.id,
52
+ role: t.role,
53
+ type: t.type,
54
+ text: t.text,
55
+ }
56
+ if (t.options !== undefined) entry.options = t.options
57
+ if (t.answer !== undefined) entry.answer = t.answer
58
+ return entry
59
+ }),
60
+ }
61
+ }
62
+
63
+ export function buildCopyMarkdown(parts: CopyParts): string {
64
+ const { origin, root, schemaHash, openComments, userContext, autoContext, documents = [] } = parts
65
+ const lines: string[] = []
66
+
67
+ // ── Header ──
68
+ lines.push(`# Domain handoff — ${origin}`)
69
+ lines.push('')
70
+ lines.push(`- **Origin:** \`${origin}\``)
71
+ lines.push(`- **Repo root:** \`${root}\``)
72
+ lines.push(`- **Schema hash:** \`${schemaHash}\``)
73
+ lines.push('')
74
+ lines.push(
75
+ 'Load the **astrale-domain** skill, use the context/threads below, ' +
76
+ 'read any listed context documents if relevant, edit code on disk, and reply by appending one `{role:"author"}` entry per open thread ' +
77
+ '(merge by `id`). Resolve a thread by adding a closing note plus `"status":"closed"`; ' +
78
+ 'a thread whose last entry is not yours will be resent.',
79
+ )
80
+ lines.push('')
81
+
82
+ // ── Context documents (paths only; contents stay on disk for the agent to read on demand) ──
83
+ if (documents.length > 0) {
84
+ lines.push('## Context documents')
85
+ lines.push('')
86
+ for (const doc of documents) {
87
+ const type = doc.type || 'application/octet-stream'
88
+ lines.push(
89
+ `- \`.domain-studio/${doc.stored}\` — ${doc.name} (${type}, ${formatBytes(doc.size)})`,
90
+ )
91
+ }
92
+ lines.push('')
93
+ }
94
+
95
+ // ── Saved context notes ──
96
+ if (userContext.length > 0) {
97
+ lines.push('## Saved context notes')
98
+ lines.push('')
99
+ for (const item of userContext) {
100
+ lines.push(`### ${item.title}`)
101
+ lines.push('')
102
+ lines.push(item.body)
103
+ lines.push('')
104
+ }
105
+ }
106
+
107
+ // ── Context (auto) — optional, caller already filtered ──
108
+ if (autoContext.length > 0) {
109
+ lines.push('## Auto context')
110
+ lines.push('')
111
+ for (const item of autoContext) {
112
+ lines.push(`### ${item.title}`)
113
+ lines.push('')
114
+ lines.push(item.body)
115
+ lines.push('')
116
+ }
117
+ }
118
+
119
+ // ── Open comments & questions ──
120
+ lines.push('## Open comments & questions')
121
+ lines.push('')
122
+ if (openComments.length === 0) {
123
+ lines.push('_none_')
124
+ lines.push('')
125
+ } else {
126
+ openComments.forEach((comment, i) => {
127
+ const anchor = primaryAnchor(comment)
128
+ const latest = latestThreadEntry(comment)
129
+ const head = `${i + 1}. **${comment.kind}**`
130
+ const where = anchor ? ` — \`${anchor.ref}\`${anchor.file ? ` (${anchor.file})` : ''}` : ''
131
+ lines.push(`${head}${where}`)
132
+ if (latest) {
133
+ lines.push(` - ${latest.role}: ${latest.text}`)
134
+ if (latest.type === 'choice') {
135
+ if (latest.options && latest.options.length > 0) {
136
+ lines.push(` - options: ${latest.options.join(', ')}`)
137
+ }
138
+ lines.push(` - answer: ${latest.answer ?? '(unanswered)'}`)
139
+ }
140
+ }
141
+ lines.push('')
142
+ })
143
+ }
144
+
145
+ // ── Trailing machine-state block ──
146
+ const machineState = {
147
+ schemaVersion: schemaHash,
148
+ comments: openComments.map(toMachineState),
149
+ }
150
+ lines.push('```json')
151
+ lines.push(JSON.stringify(machineState, null, 2))
152
+ lines.push('```')
153
+ lines.push('')
154
+
155
+ return lines.join('\n')
156
+ }