@jkwd/inbase 0.1.0

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 (44) hide show
  1. package/README.md +76 -0
  2. package/apps/explorer/index.html +12 -0
  3. package/apps/explorer/package.json +28 -0
  4. package/apps/explorer/scripts/js-source.mjs +188 -0
  5. package/apps/explorer/scripts/patch-lib.d.ts +115 -0
  6. package/apps/explorer/scripts/patch-lib.mjs +472 -0
  7. package/apps/explorer/scripts/scan-target.mjs +188 -0
  8. package/apps/explorer/scripts/session-store.d.ts +156 -0
  9. package/apps/explorer/scripts/session-store.mjs +809 -0
  10. package/apps/explorer/scripts/target-config.d.ts +8 -0
  11. package/apps/explorer/scripts/target-config.mjs +42 -0
  12. package/apps/explorer/src/App.tsx +941 -0
  13. package/apps/explorer/src/agentIntent.ts +182 -0
  14. package/apps/explorer/src/codebase.ts +15 -0
  15. package/apps/explorer/src/index.css +632 -0
  16. package/apps/explorer/src/layout.ts +508 -0
  17. package/apps/explorer/src/main.tsx +16 -0
  18. package/apps/explorer/src/scene/BlockPlacer.tsx +87 -0
  19. package/apps/explorer/src/scene/Bridge.tsx +290 -0
  20. package/apps/explorer/src/scene/FileBlock.tsx +256 -0
  21. package/apps/explorer/src/scene/FolderArea.tsx +96 -0
  22. package/apps/explorer/src/scene/IslandPlacer.tsx +40 -0
  23. package/apps/explorer/src/scene/MapSelectBorder.tsx +57 -0
  24. package/apps/explorer/src/scene/MapView.tsx +247 -0
  25. package/apps/explorer/src/scene/Player.tsx +245 -0
  26. package/apps/explorer/src/scene/RelationLines.tsx +223 -0
  27. package/apps/explorer/src/scene/SelectionController.tsx +89 -0
  28. package/apps/explorer/src/scene/UserContextTracker.tsx +152 -0
  29. package/apps/explorer/src/scene/World.tsx +323 -0
  30. package/apps/explorer/src/theme.ts +111 -0
  31. package/apps/explorer/src/types.ts +245 -0
  32. package/apps/explorer/src/ui/CanvasErrorBoundary.tsx +38 -0
  33. package/apps/explorer/src/ui/HUD.tsx +1090 -0
  34. package/apps/explorer/src/ui/NameInput.tsx +45 -0
  35. package/apps/explorer/src/userContext.ts +73 -0
  36. package/apps/explorer/src/userCreated.ts +354 -0
  37. package/apps/explorer/src/vite-env.d.ts +1 -0
  38. package/apps/explorer/tsconfig.json +21 -0
  39. package/apps/explorer/vite.config.ts +295 -0
  40. package/bin/inbase.mjs +170 -0
  41. package/bin/project.mjs +94 -0
  42. package/bin/session.mjs +241 -0
  43. package/package.json +63 -0
  44. package/skill/inbase/SKILL.md +167 -0
@@ -0,0 +1,809 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import {
5
+ accumulatePatchAdditions,
6
+ applyUnifiedPatch,
7
+ collectCreateFolders,
8
+ extractPatchImports,
9
+ foldersFromFileIds,
10
+ parseUnifiedPatch,
11
+ } from './patch-lib.mjs'
12
+
13
+ const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
14
+
15
+ export function assertSessionId(value) {
16
+ if (typeof value !== 'string' || !SESSION_ID.test(value) || value === '.' || value === '..') {
17
+ throw new Error(
18
+ 'sessionId must be 1-128 letters, numbers, dots, underscores, or hyphens',
19
+ )
20
+ }
21
+ return value
22
+ }
23
+
24
+ function readJson(file, fallback) {
25
+ try {
26
+ return JSON.parse(fs.readFileSync(file, 'utf8'))
27
+ } catch {
28
+ return fallback
29
+ }
30
+ }
31
+
32
+ function atomicWrite(file, contents) {
33
+ fs.mkdirSync(path.dirname(file), { recursive: true })
34
+ const temporary = `${file}.${process.pid}.tmp`
35
+ fs.writeFileSync(temporary, contents)
36
+ fs.renameSync(temporary, file)
37
+ }
38
+
39
+ export function sessionPaths(dataDir, sessionId) {
40
+ const safeId = assertSessionId(sessionId)
41
+ const root = path.join(dataDir, 'diff-sessions', safeId)
42
+ return {
43
+ root,
44
+ diffs: path.join(root, 'diffs'),
45
+ manifest: path.join(root, 'manifest.json'),
46
+ blueprint: path.join(root, 'blueprint.json'),
47
+ }
48
+ }
49
+
50
+ export function readActiveSession(dataDir) {
51
+ const value = readJson(path.join(dataDir, 'active-session.json'), null)
52
+ return value?.sessionId ? assertSessionId(value.sessionId) : null
53
+ }
54
+
55
+ export function writeActiveSession(dataDir, sessionId) {
56
+ atomicWrite(
57
+ path.join(dataDir, 'active-session.json'),
58
+ `${JSON.stringify({ sessionId: sessionId ? assertSessionId(sessionId) : null }, null, 2)}\n`,
59
+ )
60
+ }
61
+
62
+ export function readBlueprintSession(dataDir) {
63
+ const value = readJson(path.join(dataDir, 'blueprint-session.json'), null)
64
+ return value?.sessionId ? assertSessionId(value.sessionId) : null
65
+ }
66
+
67
+ export function writeBlueprintSession(dataDir, sessionId) {
68
+ atomicWrite(
69
+ path.join(dataDir, 'blueprint-session.json'),
70
+ `${JSON.stringify({ sessionId: sessionId ? assertSessionId(sessionId) : null }, null, 2)}\n`,
71
+ )
72
+ }
73
+
74
+ function assertBlueprintSessionAvailable(dataDir, sessionId) {
75
+ const safeId = assertSessionId(sessionId)
76
+ const locked = readBlueprintSession(dataDir)
77
+ if (locked && locked !== safeId) {
78
+ throw new Error(
79
+ `Blueprint edit mode is active in session ${locked}. Finish or stop it before starting another.`,
80
+ )
81
+ }
82
+ return safeId
83
+ }
84
+
85
+ function claimBlueprintSession(dataDir, sessionId) {
86
+ const safeId = assertBlueprintSessionAvailable(dataDir, sessionId)
87
+ writeBlueprintSession(dataDir, safeId)
88
+ return safeId
89
+ }
90
+
91
+ function releaseBlueprintSession(dataDir, sessionId) {
92
+ const safeId = assertSessionId(sessionId)
93
+ if (readBlueprintSession(dataDir) === safeId) writeBlueprintSession(dataDir, null)
94
+ }
95
+
96
+ function focusSession(dataDir, sessionId) {
97
+ const safeId = assertSessionId(sessionId)
98
+ const locked = readBlueprintSession(dataDir)
99
+ if (!locked || locked === safeId) writeActiveSession(dataDir, safeId)
100
+ }
101
+
102
+ export function readManifest(dataDir, sessionId) {
103
+ const { manifest } = sessionPaths(dataDir, sessionId)
104
+ const value = readJson(manifest, null)
105
+ if (!value || value.sessionId !== sessionId || !Array.isArray(value.diffs)) return null
106
+ if (!value.phase) {
107
+ const active = value.diffs.at(-1)
108
+ value.phase =
109
+ value.status === 'finished'
110
+ ? 'finished'
111
+ : value.status === 'rejected'
112
+ ? 'stopped'
113
+ : active?.status === 'pending'
114
+ ? 'review'
115
+ : active?.status === 'extend'
116
+ ? 'replanning'
117
+ : 'plan_ready'
118
+ value.currentStep =
119
+ value.currentStep ??
120
+ (active?.status === 'applied' ? active.step + 1 : active?.step ?? 1)
121
+ value.pendingInstruction ??= null
122
+ value.workStartedAt ??= null
123
+ }
124
+ return value
125
+ }
126
+
127
+ export function writeManifest(dataDir, manifest) {
128
+ const paths = sessionPaths(dataDir, manifest.sessionId)
129
+ manifest.updatedAt = new Date().toISOString()
130
+ atomicWrite(paths.manifest, `${JSON.stringify(manifest, null, 2)}\n`)
131
+ }
132
+
133
+ export function readDiff(dataDir, sessionId, entry) {
134
+ const paths = sessionPaths(dataDir, sessionId)
135
+ const absolute = path.resolve(paths.root, entry.file)
136
+ if (!absolute.startsWith(`${paths.root}${path.sep}`)) {
137
+ throw new Error(`Invalid diff path for ${entry.id}`)
138
+ }
139
+ return fs.readFileSync(absolute, 'utf8')
140
+ }
141
+
142
+ function entryIndex(manifest, diffId) {
143
+ const index = manifest.diffs.findIndex((entry) => entry.id === diffId)
144
+ if (index < 0) throw new Error(`Unknown diff ${diffId}`)
145
+ return index
146
+ }
147
+
148
+ export function chainThrough(manifest, diffId = manifest.activeDiffId) {
149
+ if (!diffId) return []
150
+ return manifest.diffs.slice(0, entryIndex(manifest, diffId) + 1)
151
+ }
152
+
153
+ function unresolvedEntries(manifest, diffId = manifest.activeDiffId) {
154
+ const chain = chainThrough(manifest, diffId)
155
+ let lastApplied = -1
156
+ chain.forEach((entry, index) => {
157
+ if (entry.status === 'applied') lastApplied = index
158
+ })
159
+ return chain.slice(lastApplied + 1).filter((entry) => entry.status !== 'rejected')
160
+ }
161
+
162
+ export function previewPatchChain(patches, knownFileIds = []) {
163
+ const state = new Map()
164
+ const lineCounts = new Map()
165
+ const imports = new Map()
166
+
167
+ for (const patch of patches) {
168
+ const parsed = parseUnifiedPatch(patch)
169
+ for (const edge of extractPatchImports(parsed.entries, [
170
+ ...knownFileIds,
171
+ ...state.keys(),
172
+ ])) {
173
+ imports.set(`${edge.from}->${edge.to}`, edge)
174
+ }
175
+
176
+ for (const entry of parsed.entries) {
177
+ const previous = state.get(entry.id)
178
+ if (entry.kind === 'add') {
179
+ state.set(entry.id, 'add')
180
+ lineCounts.set(entry.id, Math.max(1, entry.addedLines))
181
+ continue
182
+ }
183
+ if (entry.kind === 'delete') {
184
+ if (previous === 'add') {
185
+ state.delete(entry.id)
186
+ lineCounts.delete(entry.id)
187
+ } else {
188
+ state.set(entry.id, 'delete')
189
+ }
190
+ continue
191
+ }
192
+
193
+ state.set(entry.id, previous === 'add' ? 'add' : 'modify')
194
+ if (previous === 'add') {
195
+ const delta = entry.hunks.reduce(
196
+ (total, hunk) => total + hunk.newCount - hunk.oldCount,
197
+ 0,
198
+ )
199
+ lineCounts.set(entry.id, Math.max(1, (lineCounts.get(entry.id) ?? 1) + delta))
200
+ }
201
+ }
202
+ }
203
+
204
+ const deleted = new Set(
205
+ [...state.entries()].filter(([, kind]) => kind === 'delete').map(([id]) => id),
206
+ )
207
+ const creates = [...state.entries()]
208
+ .filter(([, kind]) => kind === 'add')
209
+ .map(([id]) => id)
210
+ return {
211
+ files: [...state.entries()]
212
+ .filter(([, kind]) => kind === 'modify')
213
+ .map(([id]) => id),
214
+ creates,
215
+ deletes: [...deleted],
216
+ createLines: Object.fromEntries(lineCounts),
217
+ createFolders: collectCreateFolders(
218
+ creates,
219
+ foldersFromFileIds(knownFileIds.filter((id) => !creates.includes(id))),
220
+ ),
221
+ imports: [...imports.values()].filter(
222
+ (edge) => !deleted.has(edge.from) && !deleted.has(edge.to),
223
+ ),
224
+ ...accumulatePatchAdditions(patches),
225
+ }
226
+ }
227
+
228
+ export function sessionIntent(dataDir, sessionId, knownFileIds = [], selectedDiffId) {
229
+ const manifest = readManifest(dataDir, sessionId)
230
+ if (!manifest) return null
231
+ const selectedId = selectedDiffId || manifest.activeDiffId
232
+ const selectedIndex = selectedId ? entryIndex(manifest, selectedId) : null
233
+ const selected =
234
+ selectedIndex === null ? null : manifest.diffs[selectedIndex]
235
+ const patches =
236
+ selectedIndex === null
237
+ ? []
238
+ : manifest.diffs
239
+ .slice(0, selectedIndex + 1)
240
+ .filter((entry) => entry.status !== 'rejected')
241
+ .map((entry) => readDiff(dataDir, sessionId, entry))
242
+ const preview = previewPatchChain(patches, knownFileIds)
243
+ const activeView = !selectedDiffId || selectedId === manifest.activeDiffId
244
+ const phaseStatus = {
245
+ blueprint_ask: 'blueprint_ask',
246
+ blueprint: 'blueprint',
247
+ preparing: 'preparing',
248
+ plan_ready: 'planned',
249
+ working: 'working',
250
+ review: 'pending',
251
+ replanning: 'replanning',
252
+ finished: 'finished',
253
+ stopped: 'rejected',
254
+ }[manifest.phase]
255
+ const historicalStatus =
256
+ selected?.status === 'applied'
257
+ ? 'approved'
258
+ : selected?.status ?? phaseStatus ?? 'idle'
259
+ const currentPlanStep = manifest.steps.find(
260
+ (step) => step.index === manifest.currentStep,
261
+ )
262
+ const previewVisible = patches.length > 0
263
+ const blueprint = readBlueprint(dataDir, sessionId)
264
+ const blueprintSessionId = readBlueprintSession(dataDir)
265
+ const ownsBlueprintLock = blueprintSessionId === sessionId
266
+ const canEnterBlueprint =
267
+ manifest.phase === 'blueprint_ask' &&
268
+ (!blueprintSessionId || ownsBlueprintLock)
269
+
270
+ return {
271
+ updatedAt: manifest.updatedAt,
272
+ showMap: previewVisible,
273
+ status: activeView ? phaseStatus ?? historicalStatus : historicalStatus,
274
+ phase: manifest.phase,
275
+ feature: manifest.feature,
276
+ steps: manifest.steps,
277
+ step: activeView ? manifest.currentStep : selected?.step ?? manifest.currentStep,
278
+ reason: activeView ? currentPlanStep?.title ?? null : selected?.title ?? null,
279
+ sessionId,
280
+ diffId: selected?.id ?? null,
281
+ parentDiffId: selected?.parentId ?? null,
282
+ chainIndex: selectedIndex,
283
+ chain: manifest.diffs.map((entry, index) => ({
284
+ id: entry.id,
285
+ index,
286
+ step: entry.step,
287
+ title: entry.title,
288
+ status: entry.status,
289
+ })),
290
+ isActiveDiff: Boolean(selected && selected.id === manifest.activeDiffId),
291
+ preview: previewVisible,
292
+ working:
293
+ manifest.phase === 'preparing' ||
294
+ manifest.phase === 'working' ||
295
+ manifest.phase === 'replanning',
296
+ creationMode: manifest.phase === 'blueprint' && ownsBlueprintLock,
297
+ canEnterBlueprint,
298
+ blueprintSessionId,
299
+ userCreatedBlocks: blueprint.userCreatedBlocks,
300
+ userCreatedIslands: blueprint.userCreatedIslands,
301
+ ...preview,
302
+ blueprintFunctions: blueprint.addedFunctions,
303
+ blueprintVariables: blueprint.addedVariables,
304
+ blueprintImports: blueprint.addedImports,
305
+ }
306
+ }
307
+
308
+ function withVirtualTarget(targetRoot, patches, action) {
309
+ const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'visual-coder-chain-'))
310
+ try {
311
+ fs.cpSync(targetRoot, temporary, { recursive: true })
312
+ for (const patchText of patches) applyUnifiedPatch(patchText, temporary)
313
+ return action(temporary)
314
+ } finally {
315
+ fs.rmSync(temporary, { recursive: true, force: true })
316
+ }
317
+ }
318
+
319
+ export function validateContinuation(dataDir, manifest, targetRoot, patchText) {
320
+ const prior = unresolvedEntries(manifest).map((entry) =>
321
+ readDiff(dataDir, manifest.sessionId, entry),
322
+ )
323
+ withVirtualTarget(targetRoot, [...prior, patchText], () => undefined)
324
+ }
325
+
326
+ function planSteps(titles, startAt = 1) {
327
+ if (!Array.isArray(titles) || titles.length === 0) {
328
+ throw new Error('A plan needs at least one step')
329
+ }
330
+ return titles.map((title, offset) => {
331
+ const trimmed = typeof title === 'string' ? title.trim() : ''
332
+ if (!trimmed) throw new Error('Plan step titles cannot be empty')
333
+ return { index: startAt + offset, title: trimmed }
334
+ })
335
+ }
336
+
337
+ function featureName(value) {
338
+ const trimmed = typeof value === 'string' ? value.trim() : ''
339
+ return trimmed
340
+ }
341
+
342
+ export function startSession(dataDir, input) {
343
+ const sessionId = assertSessionId(input.sessionId)
344
+ const existing = readManifest(dataDir, sessionId)
345
+ if (existing) {
346
+ focusSession(dataDir, sessionId)
347
+ return existing
348
+ }
349
+
350
+ const now = new Date().toISOString()
351
+ const manifest = {
352
+ version: 2,
353
+ sessionId,
354
+ feature: featureName(input.feature),
355
+ steps: [],
356
+ status: 'active',
357
+ phase: 'blueprint_ask',
358
+ currentStep: 1,
359
+ activeDiffId: null,
360
+ pendingInstruction: null,
361
+ workStartedAt: null,
362
+ createdAt: now,
363
+ updatedAt: now,
364
+ diffs: [],
365
+ }
366
+ writeManifest(dataDir, manifest)
367
+ writeBlueprint(dataDir, sessionId, emptyBlueprint())
368
+ focusSession(dataDir, sessionId)
369
+ return manifest
370
+ }
371
+
372
+ export function answerBlueprint(dataDir, sessionId, enabled) {
373
+ const manifest = readManifest(dataDir, assertSessionId(sessionId))
374
+ if (!manifest) throw new Error(`Unknown session ${sessionId}`)
375
+ if (manifest.phase !== 'blueprint_ask') {
376
+ throw new Error(`Session ${sessionId} is not asking for a blueprint`)
377
+ }
378
+ if (enabled) claimBlueprintSession(dataDir, sessionId)
379
+ const blueprint = {
380
+ ...emptyBlueprint(),
381
+ enabled: Boolean(enabled),
382
+ sent: !enabled,
383
+ }
384
+ writeBlueprint(dataDir, sessionId, blueprint)
385
+ manifest.phase = enabled ? 'blueprint' : 'preparing'
386
+ if (!enabled) manifest.workStartedAt = new Date().toISOString()
387
+ writeManifest(dataDir, manifest)
388
+ return manifest
389
+ }
390
+
391
+ export function updateBlueprint(dataDir, sessionId, input = {}) {
392
+ const safeId = assertSessionId(sessionId)
393
+ const manifest = readManifest(dataDir, safeId)
394
+ if (!manifest) throw new Error(`Unknown session ${safeId}`)
395
+ if (manifest.phase !== 'blueprint') {
396
+ throw new Error(`Session ${safeId} is not in blueprint mode`)
397
+ }
398
+ assertBlueprintSessionAvailable(dataDir, safeId)
399
+ const current = readBlueprint(dataDir, safeId)
400
+ writeBlueprint(dataDir, safeId, {
401
+ ...current,
402
+ enabled: true,
403
+ sent: false,
404
+ userCreatedBlocks: input.userCreatedBlocks ?? current.userCreatedBlocks,
405
+ userCreatedIslands: input.userCreatedIslands ?? current.userCreatedIslands,
406
+ addedFunctions: input.addedFunctions ?? current.addedFunctions,
407
+ addedVariables: input.addedVariables ?? current.addedVariables,
408
+ addedImports: input.addedImports ?? current.addedImports,
409
+ })
410
+ return readBlueprint(dataDir, safeId)
411
+ }
412
+
413
+ export function sendBlueprint(dataDir, sessionId, input = {}) {
414
+ const safeId = assertSessionId(sessionId)
415
+ const manifest = readManifest(dataDir, safeId)
416
+ if (!manifest) throw new Error(`Unknown session ${safeId}`)
417
+ if (manifest.phase !== 'blueprint') {
418
+ throw new Error(`Session ${safeId} is not in blueprint mode`)
419
+ }
420
+ assertBlueprintSessionAvailable(dataDir, safeId)
421
+ const current = readBlueprint(dataDir, safeId)
422
+ writeBlueprint(dataDir, safeId, {
423
+ enabled: true,
424
+ sent: true,
425
+ userCreatedBlocks: input.userCreatedBlocks ?? current.userCreatedBlocks,
426
+ userCreatedIslands: input.userCreatedIslands ?? current.userCreatedIslands,
427
+ addedFunctions: input.addedFunctions ?? current.addedFunctions,
428
+ addedVariables: input.addedVariables ?? current.addedVariables,
429
+ addedImports: input.addedImports ?? current.addedImports,
430
+ })
431
+ manifest.phase = 'preparing'
432
+ manifest.workStartedAt = new Date().toISOString()
433
+ writeManifest(dataDir, manifest)
434
+ releaseBlueprintSession(dataDir, safeId)
435
+ return manifest
436
+ }
437
+
438
+ export function reportPlan(dataDir, input) {
439
+ const sessionId = assertSessionId(input.sessionId)
440
+ const existing = readManifest(dataDir, sessionId)
441
+ const now = new Date().toISOString()
442
+
443
+ if (existing?.phase === 'blueprint_ask' || existing?.phase === 'blueprint') {
444
+ throw new Error(
445
+ `Session ${sessionId} is waiting for the user to finish the blueprint handshake`,
446
+ )
447
+ }
448
+
449
+ if (!existing || existing.phase === 'preparing') {
450
+ const manifest = existing ?? {
451
+ version: 2,
452
+ sessionId,
453
+ feature: input.feature,
454
+ steps: [],
455
+ status: 'active',
456
+ phase: 'preparing',
457
+ currentStep: 1,
458
+ activeDiffId: null,
459
+ pendingInstruction: null,
460
+ workStartedAt: null,
461
+ createdAt: now,
462
+ updatedAt: now,
463
+ diffs: [],
464
+ }
465
+ manifest.feature = input.feature
466
+ manifest.steps = planSteps(input.stepTitles)
467
+ manifest.status = 'active'
468
+ manifest.phase = 'plan_ready'
469
+ manifest.pendingInstruction = null
470
+ manifest.workStartedAt = null
471
+ writeManifest(dataDir, manifest)
472
+ focusSession(dataDir, sessionId)
473
+ return manifest
474
+ }
475
+
476
+ if (existing.phase !== 'replanning') {
477
+ throw new Error(`Session ${sessionId} is not waiting for a revised plan`)
478
+ }
479
+ const startAt = existing.currentStep
480
+ existing.steps = [
481
+ ...existing.steps.filter((step) => step.index < startAt),
482
+ ...planSteps(input.stepTitles, startAt),
483
+ ]
484
+ existing.phase = 'plan_ready'
485
+ existing.status = 'active'
486
+ existing.pendingInstruction = null
487
+ existing.workStartedAt = null
488
+ writeManifest(dataDir, existing)
489
+ focusSession(dataDir, sessionId)
490
+ return existing
491
+ }
492
+
493
+ export function invokeStep(dataDir, sessionId, step, targetRoot = null) {
494
+ const manifest = readManifest(dataDir, assertSessionId(sessionId))
495
+ if (!manifest) throw new Error(`Unknown session ${sessionId}`)
496
+
497
+ if (manifest.phase === 'review') {
498
+ if (!targetRoot) throw new Error('A target root is required to apply the current step')
499
+ const active = pendingActive(manifest, manifest.activeDiffId)
500
+ const last = active.step >= manifest.steps.length
501
+ const expected = last ? active.step : active.step + 1
502
+ if (step !== expected) {
503
+ throw new Error(
504
+ last
505
+ ? `Run step ${active.step} to finish`
506
+ : `Run step ${expected} to continue`,
507
+ )
508
+ }
509
+ applyUnresolved(dataDir, targetRoot, manifest, active.id)
510
+ if (last) {
511
+ manifest.phase = 'finished'
512
+ manifest.status = 'finished'
513
+ writeManifest(dataDir, manifest)
514
+ finalizeFinishedSession(dataDir, sessionId)
515
+ return manifest
516
+ }
517
+ manifest.currentStep = expected
518
+ manifest.phase = 'plan_ready'
519
+ manifest.workStartedAt = null
520
+ }
521
+
522
+ if (manifest.phase !== 'plan_ready') {
523
+ throw new Error(`Session ${sessionId} is not ready to invoke a step`)
524
+ }
525
+ if (step !== manifest.currentStep || !manifest.steps.some((item) => item.index === step)) {
526
+ throw new Error(`Step ${step} is not the current plan step`)
527
+ }
528
+ manifest.phase = 'working'
529
+ manifest.workStartedAt = new Date().toISOString()
530
+ writeManifest(dataDir, manifest)
531
+ return manifest
532
+ }
533
+
534
+ export function appendDiff(dataDir, targetRoot, input) {
535
+ const sessionId = assertSessionId(input.sessionId)
536
+ const manifest = readManifest(dataDir, sessionId)
537
+ if (!manifest) throw new Error(`Report a plan for session ${sessionId} first`)
538
+ if (manifest.phase !== 'working') {
539
+ throw new Error(`Step ${manifest.currentStep} has not been invoked`)
540
+ }
541
+
542
+ const now = new Date().toISOString()
543
+ const parent = manifest.diffs.at(-1) ?? null
544
+ const step = manifest.currentStep
545
+ const title = manifest.steps.find((item) => item.index === step)?.title
546
+ if (!title) throw new Error(`Plan step ${step} does not exist`)
547
+ if (parent && parent.status !== 'extend' && parent.status !== 'applied') {
548
+ throw new Error(`Diff ${parent.id} must be continued or replanned first`)
549
+ }
550
+ if (parent?.status === 'extend' && step !== parent.step) {
551
+ throw new Error(`A revised diff must continue step ${parent.step}`)
552
+ }
553
+ if (parent?.status === 'applied' && step !== parent.step + 1) {
554
+ throw new Error(`The next diff must implement step ${parent.step + 1}`)
555
+ }
556
+
557
+ validateContinuation(dataDir, manifest, targetRoot, input.patchText)
558
+ if (parent?.status === 'extend') parent.status = 'extended'
559
+
560
+ const id = String(manifest.diffs.length + 1).padStart(4, '0')
561
+ const file = `diffs/${id}.patch`
562
+ const entry = {
563
+ id,
564
+ file,
565
+ parentId: parent?.id ?? null,
566
+ step,
567
+ title,
568
+ status: 'pending',
569
+ instruction: null,
570
+ createdAt: now,
571
+ decidedAt: null,
572
+ }
573
+ const paths = sessionPaths(dataDir, sessionId)
574
+ fs.mkdirSync(paths.diffs, { recursive: true })
575
+ atomicWrite(
576
+ path.join(paths.root, file),
577
+ input.patchText.endsWith('\n') ? input.patchText : `${input.patchText}\n`,
578
+ )
579
+ manifest.activeDiffId = id
580
+ manifest.phase = 'review'
581
+ manifest.workStartedAt = null
582
+ manifest.diffs.push(entry)
583
+ writeManifest(dataDir, manifest)
584
+ focusSession(dataDir, sessionId)
585
+ return { manifest, entry }
586
+ }
587
+
588
+ function pendingActive(manifest, diffId) {
589
+ if (manifest.activeDiffId !== diffId) throw new Error('Stale diff decision')
590
+ const active = manifest.diffs.at(-1)
591
+ if (
592
+ manifest.phase !== 'review' ||
593
+ !active ||
594
+ active.id !== diffId ||
595
+ active.status !== 'pending'
596
+ ) {
597
+ throw new Error(`Diff ${diffId} is not ready for review`)
598
+ }
599
+ return active
600
+ }
601
+
602
+ function applyUnresolved(dataDir, targetRoot, manifest, diffId) {
603
+ const unresolved = unresolvedEntries(manifest, diffId)
604
+ const patches = unresolved.map((entry) =>
605
+ readDiff(dataDir, manifest.sessionId, entry),
606
+ )
607
+ const touched = new Set()
608
+ for (const patch of patches) {
609
+ for (const entry of parseUnifiedPatch(patch).entries) touched.add(entry.id)
610
+ }
611
+ withVirtualTarget(targetRoot, patches, (virtualRoot) => {
612
+ for (const id of touched) {
613
+ const source = path.join(virtualRoot, id)
614
+ const destination = path.join(targetRoot, id)
615
+ if (!fs.existsSync(source)) {
616
+ fs.rmSync(destination, { recursive: true, force: true })
617
+ continue
618
+ }
619
+ fs.mkdirSync(path.dirname(destination), { recursive: true })
620
+ fs.copyFileSync(source, destination)
621
+ }
622
+ })
623
+ for (const entry of unresolved) {
624
+ entry.status = 'applied'
625
+ entry.decidedAt = new Date().toISOString()
626
+ }
627
+ }
628
+
629
+ export function continueDiff(dataDir, targetRoot, sessionId, diffId) {
630
+ const manifest = readManifest(dataDir, assertSessionId(sessionId))
631
+ if (!manifest) throw new Error(`Unknown session ${sessionId}`)
632
+ const active = pendingActive(manifest, diffId)
633
+ applyUnresolved(dataDir, targetRoot, manifest, diffId)
634
+
635
+ if (active.step >= manifest.steps.length) {
636
+ manifest.phase = 'finished'
637
+ manifest.status = 'finished'
638
+ writeManifest(dataDir, manifest)
639
+ finalizeFinishedSession(dataDir, sessionId)
640
+ return manifest
641
+ } else {
642
+ manifest.currentStep = active.step + 1
643
+ manifest.phase = 'plan_ready'
644
+ manifest.workStartedAt = null
645
+ }
646
+ writeManifest(dataDir, manifest)
647
+ return manifest
648
+ }
649
+
650
+ export function requestReplan(dataDir, sessionId, diffId, instruction) {
651
+ const manifest = readManifest(dataDir, assertSessionId(sessionId))
652
+ if (!manifest) throw new Error(`Unknown session ${sessionId}`)
653
+ const active = pendingActive(manifest, diffId)
654
+ const guidance = typeof instruction === 'string' ? instruction.trim() : ''
655
+ if (!guidance) throw new Error('An alternative instruction is required')
656
+ active.status = 'extend'
657
+ active.instruction = guidance
658
+ active.decidedAt = new Date().toISOString()
659
+ manifest.phase = 'replanning'
660
+ manifest.pendingInstruction = guidance
661
+ manifest.workStartedAt = new Date().toISOString()
662
+ writeManifest(dataDir, manifest)
663
+ return manifest
664
+ }
665
+
666
+ export function stopSession(dataDir, sessionId, _diffId) {
667
+ finalizeFinishedSession(dataDir, sessionId)
668
+ return null
669
+ }
670
+
671
+ export function decideDiff(
672
+ dataDir,
673
+ targetRoot,
674
+ sessionId,
675
+ diffId,
676
+ decision,
677
+ instruction = '',
678
+ ) {
679
+ if (decision === 'approved') {
680
+ return continueDiff(dataDir, targetRoot, sessionId, diffId)
681
+ }
682
+ if (decision === 'extend') {
683
+ return requestReplan(dataDir, sessionId, diffId, instruction)
684
+ }
685
+ return stopSession(dataDir, sessionId, diffId)
686
+ }
687
+
688
+ export function closeSession(dataDir, sessionId) {
689
+ releaseBlueprintSession(dataDir, sessionId)
690
+ const active = readActiveSession(dataDir)
691
+ if (active === assertSessionId(sessionId)) writeActiveSession(dataDir, null)
692
+ }
693
+
694
+ export function finalizeFinishedSession(dataDir, sessionId) {
695
+ const safeId = assertSessionId(sessionId)
696
+ const paths = sessionPaths(dataDir, safeId)
697
+ releaseBlueprintSession(dataDir, safeId)
698
+ if (fs.existsSync(paths.root)) {
699
+ fs.rmSync(paths.root, { recursive: true, force: true })
700
+ }
701
+ const active = readActiveSession(dataDir)
702
+ if (active === safeId) writeActiveSession(dataDir, null)
703
+ }
704
+
705
+ export function emptyBlueprint() {
706
+ return {
707
+ enabled: false,
708
+ sent: false,
709
+ userCreatedBlocks: [],
710
+ userCreatedIslands: [],
711
+ addedFunctions: [],
712
+ addedVariables: [],
713
+ addedImports: [],
714
+ }
715
+ }
716
+
717
+ function namedBlueprintBlocks(value) {
718
+ if (!Array.isArray(value)) return []
719
+ return value.filter((item) => {
720
+ if (!item || typeof item !== 'object') return false
721
+ return (
722
+ typeof item.id === 'string' &&
723
+ typeof item.name === 'string' &&
724
+ item.name.trim() !== '' &&
725
+ typeof item.path === 'string' &&
726
+ typeof item.folder === 'string' &&
727
+ typeof item.x === 'number' &&
728
+ typeof item.z === 'number' &&
729
+ !item.naming
730
+ )
731
+ })
732
+ }
733
+
734
+ function namedBlueprintIslands(value) {
735
+ if (!Array.isArray(value)) return []
736
+ return value.filter((item) => {
737
+ if (!item || typeof item !== 'object') return false
738
+ return (
739
+ typeof item.id === 'string' &&
740
+ typeof item.name === 'string' &&
741
+ item.name.trim() !== '' &&
742
+ typeof item.path === 'string' &&
743
+ typeof item.parent === 'string' &&
744
+ !item.naming
745
+ )
746
+ })
747
+ }
748
+
749
+ function namedBlueprintSymbols(value) {
750
+ if (!Array.isArray(value)) return []
751
+ return value.filter((item) => {
752
+ if (!item || typeof item !== 'object') return false
753
+ return (
754
+ typeof item.name === 'string' &&
755
+ item.name.trim() !== '' &&
756
+ typeof item.file === 'string' &&
757
+ item.file.trim() !== ''
758
+ )
759
+ })
760
+ }
761
+
762
+ function namedBlueprintImportAdditions(value) {
763
+ if (!Array.isArray(value)) return []
764
+ return value.filter((item) => {
765
+ if (!item || typeof item !== 'object') return false
766
+ return (
767
+ typeof item.name === 'string' &&
768
+ item.name.trim() !== '' &&
769
+ typeof item.from === 'string' &&
770
+ item.from.trim() !== '' &&
771
+ typeof item.file === 'string' &&
772
+ item.file.trim() !== ''
773
+ )
774
+ })
775
+ }
776
+
777
+ export function readBlueprint(dataDir, sessionId) {
778
+ const { blueprint } = sessionPaths(dataDir, sessionId)
779
+ const value = readJson(blueprint, emptyBlueprint())
780
+ return {
781
+ enabled: Boolean(value?.enabled),
782
+ sent: Boolean(value?.sent),
783
+ userCreatedBlocks: namedBlueprintBlocks(value?.userCreatedBlocks),
784
+ userCreatedIslands: namedBlueprintIslands(value?.userCreatedIslands),
785
+ addedFunctions: namedBlueprintSymbols(value?.addedFunctions),
786
+ addedVariables: namedBlueprintSymbols(value?.addedVariables),
787
+ addedImports: namedBlueprintImportAdditions(value?.addedImports),
788
+ }
789
+ }
790
+
791
+ export function writeBlueprint(dataDir, sessionId, blueprint) {
792
+ const paths = sessionPaths(dataDir, sessionId)
793
+ atomicWrite(
794
+ paths.blueprint,
795
+ `${JSON.stringify(
796
+ {
797
+ enabled: Boolean(blueprint.enabled),
798
+ sent: Boolean(blueprint.sent),
799
+ userCreatedBlocks: namedBlueprintBlocks(blueprint.userCreatedBlocks),
800
+ userCreatedIslands: namedBlueprintIslands(blueprint.userCreatedIslands),
801
+ addedFunctions: namedBlueprintSymbols(blueprint.addedFunctions),
802
+ addedVariables: namedBlueprintSymbols(blueprint.addedVariables),
803
+ addedImports: namedBlueprintImportAdditions(blueprint.addedImports),
804
+ },
805
+ null,
806
+ 2,
807
+ )}\n`,
808
+ )
809
+ }