@mnstry/atelier 0.2.0-alpha.4 → 0.2.0-alpha.5

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 (53) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +38 -12
  3. package/contracts/public-api-baseline.json +57 -0
  4. package/docs/assurance-controls.md +39 -0
  5. package/docs/atelier-runtime.md +15 -0
  6. package/docs/blocks/claims.md +15 -9
  7. package/docs/design.md +12 -6
  8. package/docs/install.md +26 -4
  9. package/docs/knowledge-graph.md +8 -4
  10. package/docs/local-services.md +101 -0
  11. package/docs/release-engineering.md +75 -10
  12. package/docs/repo-boundary-guard.md +12 -2
  13. package/docs/upgrade.md +25 -2
  14. package/fixtures/projects/sample-workspace/content/source.html.kg.json +4 -1
  15. package/fixtures/projects/source-formats-workspace/content/data.json.kg.json +4 -1
  16. package/fixtures/projects/source-formats-workspace/content/logo.png.kg.json +4 -1
  17. package/fixtures/projects/source-formats-workspace/content/metrics.csv.kg.json +4 -1
  18. package/fixtures/projects/source-formats-workspace/content/pipeline.yaml.kg.json +4 -1
  19. package/package.json +12 -5
  20. package/skills/claude/atelier-local-service/SKILL.md +47 -0
  21. package/skills/claude/atelier-public-boundary/SKILL.md +31 -0
  22. package/skills/codex/atelier-local-service/SKILL.md +47 -0
  23. package/skills/codex/atelier-public-boundary/SKILL.md +31 -0
  24. package/src/boundary/content-rules.mjs +278 -20
  25. package/src/boundary/policy.mjs +150 -60
  26. package/src/cli/execute-command.mjs +36 -0
  27. package/src/cli/run.mjs +17 -7
  28. package/src/collaboration/event-ledger.mjs +365 -0
  29. package/src/collaboration/index.mjs +17 -0
  30. package/src/collaboration/proposals.mjs +265 -65
  31. package/src/commands/attestation.mjs +20 -6
  32. package/src/commands/disclosure.mjs +133 -0
  33. package/src/commands/distribution.mjs +2 -1
  34. package/src/commands/extension-pack.mjs +2 -1
  35. package/src/commands/init.mjs +2 -1
  36. package/src/commands/server.mjs +1 -4
  37. package/src/disclosure/content-scan.mjs +193 -0
  38. package/src/egress/check.mjs +7 -38
  39. package/src/egress/forbidden-egress.mjs +32 -18
  40. package/src/graph/graph.mjs +112 -314
  41. package/src/graph/knowledge-graph.mjs +94 -18
  42. package/src/harness/context-client.mjs +9 -1
  43. package/src/index.mjs +12 -0
  44. package/src/project/config.mjs +66 -7
  45. package/src/project/file-class.mjs +14 -0
  46. package/src/project/package-root.mjs +10 -0
  47. package/src/project/path-match.mjs +38 -15
  48. package/src/project/private-state.mjs +110 -0
  49. package/src/server/local-sidecar.mjs +81 -59
  50. package/src/server/security.mjs +89 -4
  51. package/src/server/server.mjs +3 -2
  52. package/src/support/feedback-report.mjs +4 -3
  53. package/src/upgrade/upgrade.mjs +2 -1
@@ -0,0 +1,365 @@
1
+ import crypto from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { canonicalize } from '../attestation/jcs.mjs'
5
+ import {
6
+ atomicReplacePrivateText,
7
+ ensureContainedPrivateDirectory,
8
+ openRegularFileNoFollow,
9
+ } from '../project/private-state.mjs'
10
+
11
+ export const ATELIER_COLLABORATION_EVENT_SCHEMA = 'atelier-collaboration-event@v1'
12
+ export const COLLABORATION_LEDGER_LIMITS = Object.freeze({
13
+ maxLineBytes: 256 * 1024,
14
+ maxBytes: 16 * 1024 * 1024,
15
+ maxEvents: 10_000,
16
+ retainPerAggregate: 50,
17
+ retainDays: 180,
18
+ })
19
+
20
+ function isRecord(value) {
21
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
22
+ }
23
+
24
+ function validTimestamp(value) {
25
+ return typeof value === 'string' && Number.isFinite(Date.parse(value))
26
+ }
27
+
28
+ function secureAppendLine(file, line) {
29
+ const descriptor = openRegularFileNoFollow(
30
+ file,
31
+ fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT,
32
+ 0o600,
33
+ )
34
+ try {
35
+ fs.writeFileSync(descriptor, line)
36
+ fs.fsyncSync(descriptor)
37
+ fs.fchmodSync(descriptor, 0o600)
38
+ } finally {
39
+ fs.closeSync(descriptor)
40
+ }
41
+ }
42
+
43
+ function eventId(event) {
44
+ const authoritative = {
45
+ schema: event.schema,
46
+ aggregateId: event.aggregateId,
47
+ version: event.version,
48
+ type: event.type,
49
+ actor: event.actor,
50
+ at: event.at,
51
+ payload: event.payload,
52
+ }
53
+ const digest = crypto
54
+ .createHash('sha256')
55
+ .update(canonicalize(authoritative))
56
+ .digest('hex')
57
+ .slice(0, 32)
58
+ return `event-${digest}`
59
+ }
60
+
61
+ function boundedLimit(value, hardLimit) {
62
+ return Number.isFinite(value) && value > 0 ? Math.min(Math.floor(value), hardLimit) : hardLimit
63
+ }
64
+
65
+ export function validateCollaborationEvent(event) {
66
+ const issues = []
67
+ if (!isRecord(event)) return { ok: false, issues: ['event must be an object'] }
68
+ if (event.schema !== ATELIER_COLLABORATION_EVENT_SCHEMA) issues.push('event schema is unsupported')
69
+ if (typeof event.id !== 'string' || !/^event-[a-f0-9]{32}$/.test(event.id)) issues.push('event id is invalid')
70
+ if (typeof event.aggregateId !== 'string' || !event.aggregateId) issues.push('aggregateId is required')
71
+ if (!Number.isInteger(event.version) || event.version < 1) issues.push('version must be a positive integer')
72
+ if (typeof event.type !== 'string' || !/^[a-z][a-z0-9.-]*$/.test(event.type)) issues.push('event type is invalid')
73
+ if (typeof event.actor !== 'string' || !event.actor.trim()) issues.push('actor is required')
74
+ if (!validTimestamp(event.at)) issues.push('event timestamp is invalid')
75
+ if (!isRecord(event.payload)) issues.push('event payload must be an object')
76
+ if (
77
+ typeof event.id === 'string' && /^event-[a-f0-9]{32}$/.test(event.id) &&
78
+ typeof event.aggregateId === 'string' && event.aggregateId &&
79
+ Number.isInteger(event.version) && event.version > 0 &&
80
+ typeof event.type === 'string' && /^[a-z][a-z0-9.-]*$/.test(event.type) &&
81
+ typeof event.actor === 'string' && event.actor.trim() &&
82
+ validTimestamp(event.at) && isRecord(event.payload)
83
+ ) {
84
+ try {
85
+ if (event.id !== eventId(event)) issues.push('event id does not match authoritative event content')
86
+ } catch {
87
+ issues.push('authoritative event content cannot be canonicalized')
88
+ }
89
+ }
90
+ return issues.length === 0 ? { ok: true, value: event } : { ok: false, issues }
91
+ }
92
+
93
+ export function createCollaborationEventLedger({
94
+ workspaceRoot = process.cwd(),
95
+ ledgerPath = path.join(workspaceRoot, '.atelier-proposals', 'events.ndjson'),
96
+ clock = () => new Date().toISOString(),
97
+ maxLineBytes = COLLABORATION_LEDGER_LIMITS.maxLineBytes,
98
+ maxBytes = COLLABORATION_LEDGER_LIMITS.maxBytes,
99
+ maxEvents = COLLABORATION_LEDGER_LIMITS.maxEvents,
100
+ } = {}) {
101
+ const root = fs.realpathSync(workspaceRoot)
102
+ const resolvedLedger = path.resolve(ledgerPath)
103
+ const ledgerDirReal = ensureContainedPrivateDirectory({
104
+ workspaceRoot,
105
+ directory: path.dirname(resolvedLedger),
106
+ label: 'collaboration ledger directory',
107
+ })
108
+ const ledgerReal = path.join(ledgerDirReal, path.basename(resolvedLedger))
109
+ const relative = path.relative(root, ledgerReal)
110
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
111
+ throw new Error('collaboration ledger escapes workspace')
112
+ }
113
+
114
+ const limits = Object.freeze({
115
+ maxLineBytes: boundedLimit(maxLineBytes, COLLABORATION_LEDGER_LIMITS.maxLineBytes),
116
+ maxBytes: boundedLimit(maxBytes, COLLABORATION_LEDGER_LIMITS.maxBytes),
117
+ maxEvents: boundedLimit(maxEvents, COLLABORATION_LEDGER_LIMITS.maxEvents),
118
+ })
119
+ const lockPath = `${ledgerReal}.lock`
120
+
121
+ function failure(status, error, diagnostics = [], stats = {}) {
122
+ return { ok: false, status, error, events: [], diagnostics, stats }
123
+ }
124
+
125
+ function readAll() {
126
+ const startedAt = performance.now()
127
+ if (!fs.existsSync(ledgerReal)) {
128
+ return { ok: true, status: 200, events: [], diagnostics: [], stats: { bytes: 0, eventCount: 0, durationMs: performance.now() - startedAt } }
129
+ }
130
+
131
+ let descriptor
132
+ let bytes
133
+ try {
134
+ descriptor = openRegularFileNoFollow(ledgerReal, fs.constants.O_RDONLY)
135
+ bytes = fs.fstatSync(descriptor).size
136
+ } catch (error) {
137
+ return failure(500, `collaboration ledger cannot be inspected: ${error.message}`)
138
+ }
139
+ if (bytes > limits.maxBytes) {
140
+ fs.closeSync(descriptor)
141
+ return failure(413, `collaboration ledger exceeds ${limits.maxBytes} byte hard ceiling`, [{ code: 'ledger-byte-limit', bytes }], { bytes, eventCount: 0 })
142
+ }
143
+
144
+ let raw
145
+ try {
146
+ raw = fs.readFileSync(descriptor, 'utf8')
147
+ } catch (error) {
148
+ return failure(500, `collaboration ledger cannot be read: ${error.message}`, [], { bytes, eventCount: 0 })
149
+ } finally {
150
+ fs.closeSync(descriptor)
151
+ }
152
+
153
+ const diagnostics = []
154
+ const events = []
155
+ const versions = new Map()
156
+ const eventIds = new Set()
157
+ const lines = raw.split('\n')
158
+ for (let index = 0; index < lines.length; index += 1) {
159
+ const line = lines[index]
160
+ if (!line) continue
161
+ const lineNumber = index + 1
162
+ const lineBytes = Buffer.byteLength(line)
163
+ if (lineBytes > limits.maxLineBytes) {
164
+ diagnostics.push({ line: lineNumber, code: 'ledger-line-limit', message: `line exceeds ${limits.maxLineBytes} bytes` })
165
+ continue
166
+ }
167
+ if (events.length >= limits.maxEvents) {
168
+ diagnostics.push({ line: lineNumber, code: 'ledger-event-limit', message: `ledger exceeds ${limits.maxEvents} events` })
169
+ break
170
+ }
171
+ let event
172
+ try {
173
+ event = JSON.parse(line)
174
+ } catch (error) {
175
+ diagnostics.push({ line: lineNumber, code: 'ledger-json-invalid', message: error.message })
176
+ continue
177
+ }
178
+ const validation = validateCollaborationEvent(event)
179
+ if (!validation.ok) {
180
+ diagnostics.push({ line: lineNumber, code: 'ledger-event-invalid', message: validation.issues.join('; ') })
181
+ continue
182
+ }
183
+ if (eventIds.has(event.id)) {
184
+ diagnostics.push({ line: lineNumber, code: 'ledger-event-duplicate', message: `duplicate event id ${event.id}` })
185
+ continue
186
+ }
187
+ const previousVersion = versions.get(event.aggregateId) ?? 0
188
+ if (event.version <= previousVersion) {
189
+ diagnostics.push({ line: lineNumber, code: 'ledger-version-order', message: `version ${event.version} does not advance aggregate ${event.aggregateId}` })
190
+ continue
191
+ }
192
+ eventIds.add(event.id)
193
+ versions.set(event.aggregateId, event.version)
194
+ events.push(event)
195
+ }
196
+
197
+ const stats = { bytes, eventCount: events.length, durationMs: performance.now() - startedAt }
198
+ if (diagnostics.length) {
199
+ return { ok: false, status: 422, error: `collaboration ledger validation failed at ${diagnostics.length} line(s)`, events, diagnostics, stats }
200
+ }
201
+ return { ok: true, status: 200, events, diagnostics: [], stats }
202
+ }
203
+
204
+ function eventsFor(aggregateId) {
205
+ const id = String(aggregateId || '').trim()
206
+ if (!id) return failure(400, 'aggregateId is required')
207
+ const result = readAll()
208
+ if (!result.ok) return result
209
+ const events = result.events.filter((event) => event.aggregateId === id)
210
+ return {
211
+ ...result,
212
+ events,
213
+ currentVersion: events.at(-1)?.version ?? 0,
214
+ }
215
+ }
216
+
217
+ function withWriteLock(operation) {
218
+ let descriptor
219
+ try {
220
+ descriptor = fs.openSync(lockPath, 'wx', 0o600)
221
+ fs.writeFileSync(descriptor, `${process.pid}\n`)
222
+ fs.fsyncSync(descriptor)
223
+ } catch (error) {
224
+ if (descriptor != null) fs.closeSync(descriptor)
225
+ if (error?.code === 'EEXIST') return { ok: false, status: 423, error: 'collaboration ledger is locked; retry after the active writer finishes' }
226
+ return { ok: false, status: 500, error: `collaboration ledger lock failed: ${error.message}` }
227
+ }
228
+ try {
229
+ return operation()
230
+ } finally {
231
+ fs.closeSync(descriptor)
232
+ try {
233
+ fs.unlinkSync(lockPath)
234
+ } catch {
235
+ // The completed write remains authoritative; cleanup can be diagnosed
236
+ // by the next writer's explicit locked result.
237
+ }
238
+ }
239
+ }
240
+
241
+ function append({ aggregateId, expectedVersion = 0, type, actor, at = clock(), payload = {} }) {
242
+ return withWriteLock(() => {
243
+ const current = eventsFor(aggregateId)
244
+ if (!current.ok) return current
245
+ if (current.currentVersion !== expectedVersion) {
246
+ return {
247
+ ok: false,
248
+ status: 409,
249
+ error: `stale collaboration event refused: expected ${expectedVersion}, current ${current.currentVersion}`,
250
+ }
251
+ }
252
+ if (current.stats.eventCount >= limits.maxEvents) {
253
+ return { ok: false, status: 413, error: `collaboration ledger reached ${limits.maxEvents} event hard ceiling; compact it explicitly before appending` }
254
+ }
255
+ const version = current.currentVersion + 1
256
+ const event = {
257
+ schema: ATELIER_COLLABORATION_EVENT_SCHEMA,
258
+ aggregateId,
259
+ version,
260
+ type,
261
+ actor: String(actor || '').trim(),
262
+ at,
263
+ payload,
264
+ }
265
+ try {
266
+ event.id = eventId(event)
267
+ } catch {
268
+ return { ok: false, status: 400, error: 'authoritative event content cannot be canonicalized' }
269
+ }
270
+ const validation = validateCollaborationEvent(event)
271
+ if (!validation.ok) return { ok: false, status: 400, error: validation.issues.join('; ') }
272
+ const line = `${JSON.stringify(event)}\n`
273
+ const lineBytes = Buffer.byteLength(line)
274
+ if (lineBytes > limits.maxLineBytes) {
275
+ return { ok: false, status: 413, error: `collaboration event exceeds ${limits.maxLineBytes} byte line ceiling` }
276
+ }
277
+ if (current.stats.bytes + lineBytes > limits.maxBytes) {
278
+ return { ok: false, status: 413, error: `collaboration ledger would exceed ${limits.maxBytes} byte hard ceiling; compact it explicitly before appending` }
279
+ }
280
+ try {
281
+ secureAppendLine(ledgerReal, line)
282
+ } catch (error) {
283
+ return { ok: false, status: 500, error: `collaboration ledger cannot be appended: ${error.message}` }
284
+ }
285
+ return { ok: true, status: 200, event }
286
+ })
287
+ }
288
+
289
+ function materialize(aggregateId, reducer, initial = null) {
290
+ if (typeof reducer !== 'function') return { ok: false, status: 400, error: 'reducer is required', value: initial }
291
+ const result = eventsFor(aggregateId)
292
+ if (!result.ok) return { ...result, value: initial }
293
+ return {
294
+ ...result,
295
+ value: result.events.reduce((state, event) => reducer(state, event), initial),
296
+ }
297
+ }
298
+
299
+ function compact({
300
+ now = clock(),
301
+ retainPerAggregate = COLLABORATION_LEDGER_LIMITS.retainPerAggregate,
302
+ retainDays = COLLABORATION_LEDGER_LIMITS.retainDays,
303
+ } = {}) {
304
+ return withWriteLock(() => {
305
+ const current = readAll()
306
+ if (!current.ok) return current
307
+ const perAggregate = Math.max(2, boundedLimit(retainPerAggregate, COLLABORATION_LEDGER_LIMITS.retainPerAggregate))
308
+ const days = boundedLimit(retainDays, COLLABORATION_LEDGER_LIMITS.retainDays)
309
+ const cutoff = Date.parse(now) - days * 24 * 60 * 60 * 1000
310
+ if (!Number.isFinite(cutoff)) return { ok: false, status: 400, error: 'compaction timestamp is invalid' }
311
+
312
+ const grouped = new Map()
313
+ for (const event of current.events) {
314
+ const group = grouped.get(event.aggregateId) ?? []
315
+ group.push(event)
316
+ grouped.set(event.aggregateId, group)
317
+ }
318
+ const kept = []
319
+ for (const events of grouped.values()) {
320
+ const first = events[0]
321
+ const latest = events.at(-1)
322
+ const recent = events.filter((event) => Date.parse(event.at) >= cutoff)
323
+ const candidates = [first, ...recent, latest]
324
+ const unique = [...new Map(candidates.map((event) => [event.id, event])).values()]
325
+ .sort((left, right) => left.version - right.version)
326
+ const selected = unique.length <= perAggregate
327
+ ? unique
328
+ : [unique[0], ...unique.slice(-(perAggregate - 1))]
329
+ kept.push(...selected)
330
+ }
331
+ // `at` is contributor-controlled metadata and may regress. The physical
332
+ // ledger must retain each aggregate's causal version order after rewrite.
333
+ kept.sort((left, right) => (
334
+ left.aggregateId.localeCompare(right.aggregateId) ||
335
+ left.version - right.version ||
336
+ left.id.localeCompare(right.id)
337
+ ))
338
+ const text = kept.length ? `${kept.map((event) => JSON.stringify(event)).join('\n')}\n` : ''
339
+ if (Buffer.byteLength(text) > limits.maxBytes) {
340
+ return { ok: false, status: 413, error: 'compacted collaboration ledger still exceeds the byte hard ceiling' }
341
+ }
342
+ atomicReplacePrivateText(ledgerReal, text)
343
+ return {
344
+ ok: true,
345
+ status: 200,
346
+ before: current.events.length,
347
+ after: kept.length,
348
+ removed: current.events.length - kept.length,
349
+ retainPerAggregate: perAggregate,
350
+ retainDays: days,
351
+ }
352
+ })
353
+ }
354
+
355
+ return Object.freeze({
356
+ ledgerPath: ledgerReal,
357
+ lockPath,
358
+ limits,
359
+ readAll,
360
+ eventsFor,
361
+ append,
362
+ materialize,
363
+ compact,
364
+ })
365
+ }
@@ -0,0 +1,17 @@
1
+ export {
2
+ ATELIER_COLLABORATION_EVENT_SCHEMA,
3
+ createCollaborationEventLedger,
4
+ validateCollaborationEvent,
5
+ } from './event-ledger.mjs'
6
+
7
+ export {
8
+ ATELIER_PROPOSAL_SCHEMA,
9
+ ATELIER_PROPOSALS_SCHEMA,
10
+ COPY_ONLY_PROPOSAL_CAPABILITY,
11
+ PROPOSAL_REVIEW_STATUSES,
12
+ acceptedProposalCopy,
13
+ canTransitionProposal,
14
+ copyOnlyActionSummary,
15
+ createProposalStore,
16
+ validateCopyOnlyProposalAuthority,
17
+ } from './proposals.mjs'