@jkwd/inbase 0.1.5 → 0.1.6

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.
package/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  <p align="center">
2
- <img src="docs/inbase-logo.png" alt="InBase — Dive into your codebase" width="520" />
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="docs/inbase-logo-white.png" />
4
+ <img src="docs/inbase-logo.png" alt="InBase — Dive into your codebase" width="520" />
5
+ </picture>
3
6
  </p>
4
7
 
5
8
  A first-person 3D map of a JavaScript or TypeScript codebase. Files become blocks, folders become walkable areas, and imports become lines in the air.
@@ -46,14 +46,23 @@ export function extractPatchAdditions(
46
46
  addedFunctions: PatchSymbolAddition[]
47
47
  addedVariables: PatchSymbolAddition[]
48
48
  addedImports: PatchImportAddition[]
49
+ changedFunctions: PatchSymbolAddition[]
50
+ changedVariables: PatchSymbolAddition[]
49
51
  }
50
52
 
51
53
  export function accumulatePatchAdditions(patches?: string[]): {
52
54
  addedFunctions: PatchSymbolAddition[]
53
55
  addedVariables: PatchSymbolAddition[]
54
56
  addedImports: PatchImportAddition[]
57
+ changedFunctions: PatchSymbolAddition[]
58
+ changedVariables: PatchSymbolAddition[]
55
59
  }
56
60
 
61
+ export function applyUnifiedPatchToContents(
62
+ files: Map<string, string>,
63
+ patch: string,
64
+ ): ReturnType<typeof parseUnifiedPatch>
65
+
57
66
  export function applyUnifiedPatch(
58
67
  patch: string,
59
68
  targetRoot: string,
@@ -74,6 +83,7 @@ export const emptyIntent: {
74
83
  feature: null
75
84
  steps: unknown[]
76
85
  step: null
86
+ stepByStep: boolean
77
87
  files: string[]
78
88
  creates: string[]
79
89
  deletes: string[]
@@ -83,6 +93,8 @@ export const emptyIntent: {
83
93
  addedFunctions: PatchSymbolAddition[]
84
94
  addedVariables: PatchSymbolAddition[]
85
95
  addedImports: PatchImportAddition[]
96
+ changedFunctions: PatchSymbolAddition[]
97
+ changedVariables: PatchSymbolAddition[]
86
98
  reason: null
87
99
  sessionId: null
88
100
  diffId: null
@@ -93,6 +105,7 @@ export const emptyIntent: {
93
105
  preview: boolean
94
106
  phase: null
95
107
  working: boolean
108
+ stalledWait: boolean
96
109
  creationMode: boolean
97
110
  canEnterBlueprint: boolean
98
111
  blueprintSessionId: string | null
@@ -39,7 +39,7 @@ function parseHunks(section) {
39
39
  let current = null
40
40
 
41
41
  for (const line of lines) {
42
- const header = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/)
42
+ const header = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/)
43
43
  if (header) {
44
44
  if (current) hunks.push(current)
45
45
  current = {
@@ -47,6 +47,7 @@ function parseHunks(section) {
47
47
  oldCount: Number(header[2] ?? '1'),
48
48
  newStart: Number(header[3]),
49
49
  newCount: Number(header[4] ?? '1'),
50
+ headerContext: (header[5] ?? '').trim(),
50
51
  lines: [],
51
52
  }
52
53
  continue
@@ -182,30 +183,107 @@ function emptyAdditions() {
182
183
  addedFunctions: [],
183
184
  addedVariables: [],
184
185
  addedImports: [],
186
+ changedFunctions: [],
187
+ changedVariables: [],
185
188
  }
186
189
  }
187
190
 
191
+ function isImportLine(line) {
192
+ return /^\s*import\b/.test(line)
193
+ }
194
+
195
+ function isTopLevelSymbolLine(line) {
196
+ return extractSymbols(line).length > 0 && !/^\s/.test(line)
197
+ }
198
+
199
+ function enclosingChangedSymbols(entry) {
200
+ if (entry.kind === 'add') return []
201
+ const found = []
202
+ const seen = new Set()
203
+
204
+ for (const hunk of entry.hunks) {
205
+ const contextLines = []
206
+ let addedTopLevel = false
207
+ let hasBodyEdit = false
208
+ for (const line of hunk.lines) {
209
+ const tag = line[0]
210
+ const body = line.slice(1)
211
+ if (tag === ' ') contextLines.push(body)
212
+ if (tag !== '+' && tag !== '-') continue
213
+ if (tag === '+' && isTopLevelSymbolLine(body)) addedTopLevel = true
214
+ if (
215
+ !addedTopLevel &&
216
+ extractSymbols(body).length === 0 &&
217
+ !isImportLine(body) &&
218
+ body.trim()
219
+ ) {
220
+ hasBodyEdit = true
221
+ }
222
+ }
223
+ if (!hasBodyEdit) continue
224
+ for (const symbol of [
225
+ ...extractSymbols(hunk.headerContext ?? ''),
226
+ ...extractSymbols(contextLines.join('\n')),
227
+ ]) {
228
+ const key = `${symbol.kind}:${symbol.name}`
229
+ if (seen.has(key)) continue
230
+ seen.add(key)
231
+ found.push(symbol)
232
+ }
233
+ }
234
+
235
+ return found
236
+ }
237
+
188
238
  function applyEntriesToAdditions(current, entries) {
189
239
  const functions = new Map(
190
- current.addedFunctions.map((item) => [additionKey(item.file, item.name), item]),
240
+ (current.addedFunctions ?? []).map((item) => [additionKey(item.file, item.name), item]),
191
241
  )
192
242
  const variables = new Map(
193
- current.addedVariables.map((item) => [additionKey(item.file, item.name), item]),
243
+ (current.addedVariables ?? []).map((item) => [additionKey(item.file, item.name), item]),
244
+ )
245
+ const changedFunctions = new Map(
246
+ (current.changedFunctions ?? []).map((item) => [
247
+ additionKey(item.file, item.name),
248
+ item,
249
+ ]),
250
+ )
251
+ const changedVariables = new Map(
252
+ (current.changedVariables ?? []).map((item) => [
253
+ additionKey(item.file, item.name),
254
+ item,
255
+ ]),
194
256
  )
195
257
  const imports = new Map(
196
- current.addedImports.map((item) => [additionKey(item.file, item.name, item.from), item]),
258
+ (current.addedImports ?? []).map((item) => [
259
+ additionKey(item.file, item.name, item.from),
260
+ item,
261
+ ]),
197
262
  )
198
263
 
199
- const dropFile = (fileId) => {
200
- for (const key of [...functions.keys()]) {
201
- if (functions.get(key).file === fileId) functions.delete(key)
264
+ const dropFrom = (bucket, fileId) => {
265
+ for (const key of [...bucket.keys()]) {
266
+ if (bucket.get(key).file === fileId) bucket.delete(key)
202
267
  }
203
- for (const key of [...variables.keys()]) {
204
- if (variables.get(key).file === fileId) variables.delete(key)
205
- }
206
- for (const key of [...imports.keys()]) {
207
- if (imports.get(key).file === fileId) imports.delete(key)
268
+ }
269
+
270
+ const dropFile = (fileId) => {
271
+ dropFrom(functions, fileId)
272
+ dropFrom(variables, fileId)
273
+ dropFrom(changedFunctions, fileId)
274
+ dropFrom(changedVariables, fileId)
275
+ dropFrom(imports, fileId)
276
+ }
277
+
278
+ const markChanged = (kind, file, name) => {
279
+ const key = additionKey(file, name)
280
+ if (kind === 'function') {
281
+ if (functions.has(key)) return
282
+ changedFunctions.set(key, { name, file })
283
+ return
208
284
  }
285
+ if (variables.has(key)) return
286
+ changedVariables.set(key, { name, file })
209
287
  }
210
288
 
211
289
  for (const entry of entries) {
@@ -224,15 +302,36 @@ function applyEntriesToAdditions(current, entries) {
224
302
  )
225
303
 
226
304
  for (const symbol of removedSymbols) {
227
- if (keptSymbolKeys.has(`${symbol.kind}:${symbol.name}`)) continue
228
- if (symbol.kind === 'function') functions.delete(additionKey(entry.id, symbol.name))
229
- else variables.delete(additionKey(entry.id, symbol.name))
305
+ const key = additionKey(entry.id, symbol.name)
306
+ if (keptSymbolKeys.has(`${symbol.kind}:${symbol.name}`)) {
307
+ markChanged(symbol.kind, entry.id, symbol.name)
308
+ continue
309
+ }
310
+ if (symbol.kind === 'function') {
311
+ functions.delete(key)
312
+ changedFunctions.delete(key)
313
+ } else {
314
+ variables.delete(key)
315
+ changedVariables.delete(key)
316
+ }
230
317
  }
231
318
  for (const symbol of addedSymbols) {
232
- if (previousSymbolKeys.has(`${symbol.kind}:${symbol.name}`)) continue
319
+ if (previousSymbolKeys.has(`${symbol.kind}:${symbol.name}`)) {
320
+ markChanged(symbol.kind, entry.id, symbol.name)
321
+ continue
322
+ }
233
323
  const item = { name: symbol.name, file: entry.id }
234
- if (symbol.kind === 'function') functions.set(additionKey(entry.id, symbol.name), item)
235
- else variables.set(additionKey(entry.id, symbol.name), item)
324
+ const key = additionKey(entry.id, symbol.name)
325
+ if (symbol.kind === 'function') {
326
+ changedFunctions.delete(key)
327
+ functions.set(key, item)
328
+ } else {
329
+ changedVariables.delete(key)
330
+ variables.set(key, item)
331
+ }
332
+ }
333
+ for (const symbol of enclosingChangedSymbols(entry)) {
334
+ markChanged(symbol.kind, entry.id, symbol.name)
236
335
  }
237
336
 
238
337
  const removedBindings = extractImportBindings(removedSource(entry))
@@ -262,6 +361,8 @@ function applyEntriesToAdditions(current, entries) {
262
361
  addedFunctions: [...functions.values()],
263
362
  addedVariables: [...variables.values()],
264
363
  addedImports: [...imports.values()],
364
+ changedFunctions: [...changedFunctions.values()],
365
+ changedVariables: [...changedVariables.values()],
265
366
  }
266
367
  }
267
368
 
@@ -345,30 +446,58 @@ function applyHunks(original, hunks) {
345
446
  return `${lines.join('\n')}\n`
346
447
  }
347
448
 
348
- export function applyUnifiedPatch(patch, targetRoot) {
449
+ export function applyUnifiedPatchToContents(files, patch) {
349
450
  const parsed = parseUnifiedPatch(patch)
350
451
  if (parsed.entries.length === 0) {
351
452
  throw new Error('Patch did not contain any file changes')
352
453
  }
353
454
 
354
455
  for (const file of parsed.entries) {
355
- const absolute = path.join(targetRoot, file.id)
356
456
  if (file.kind === 'delete') {
357
- if (fs.existsSync(absolute)) fs.rmSync(absolute)
457
+ files.delete(file.id)
358
458
  continue
359
459
  }
360
460
 
361
461
  const original =
362
- file.kind === 'add' || !fs.existsSync(absolute)
363
- ? ''
364
- : fs.readFileSync(absolute, 'utf8')
462
+ file.kind === 'add' || !files.has(file.id) ? '' : files.get(file.id)
365
463
 
366
464
  if (file.kind === 'modify' && original === '') {
367
465
  throw new Error(`Cannot modify missing file ${file.id}`)
368
466
  }
369
467
 
468
+ files.set(file.id, applyHunks(original, file.hunks))
469
+ }
470
+
471
+ return parsed
472
+ }
473
+
474
+ export function applyUnifiedPatch(patch, targetRoot) {
475
+ const parsed = parseUnifiedPatch(patch)
476
+ if (parsed.entries.length === 0) {
477
+ throw new Error('Patch did not contain any file changes')
478
+ }
479
+
480
+ const files = new Map()
481
+ for (const file of parsed.entries) {
482
+ const absolute = path.join(targetRoot, file.id)
483
+ if (
484
+ file.kind !== 'add' &&
485
+ fs.existsSync(absolute) &&
486
+ fs.statSync(absolute).isFile()
487
+ ) {
488
+ files.set(file.id, fs.readFileSync(absolute, 'utf8'))
489
+ }
490
+ }
491
+ applyUnifiedPatchToContents(files, patch)
492
+
493
+ for (const file of parsed.entries) {
494
+ const absolute = path.join(targetRoot, file.id)
495
+ if (file.kind === 'delete') {
496
+ if (fs.existsSync(absolute)) fs.rmSync(absolute)
497
+ continue
498
+ }
370
499
  fs.mkdirSync(path.dirname(absolute), { recursive: true })
371
- fs.writeFileSync(absolute, applyHunks(original, file.hunks))
500
+ fs.writeFileSync(absolute, files.get(file.id))
372
501
  }
373
502
 
374
503
  return parsed
@@ -381,6 +510,7 @@ export const emptyIntent = {
381
510
  feature: null,
382
511
  steps: [],
383
512
  step: null,
513
+ stepByStep: true,
384
514
  files: [],
385
515
  creates: [],
386
516
  deletes: [],
@@ -390,6 +520,8 @@ export const emptyIntent = {
390
520
  addedFunctions: [],
391
521
  addedVariables: [],
392
522
  addedImports: [],
523
+ changedFunctions: [],
524
+ changedVariables: [],
393
525
  reason: null,
394
526
  sessionId: null,
395
527
  diffId: null,
@@ -400,6 +532,7 @@ export const emptyIntent = {
400
532
  preview: false,
401
533
  phase: null,
402
534
  working: false,
535
+ stalledWait: false,
403
536
  creationMode: false,
404
537
  canEnterBlueprint: false,
405
538
  blueprintSessionId: null,
@@ -435,6 +568,8 @@ export function overlayPatch(intent, patchText, knownFileIds = []) {
435
568
  addedFunctions: [],
436
569
  addedVariables: [],
437
570
  addedImports: [],
571
+ changedFunctions: [],
572
+ changedVariables: [],
438
573
  }
439
574
  }
440
575
 
@@ -451,6 +586,8 @@ export function overlayPatch(intent, patchText, knownFileIds = []) {
451
586
  addedFunctions: base.addedFunctions ?? [],
452
587
  addedVariables: base.addedVariables ?? [],
453
588
  addedImports: base.addedImports ?? [],
589
+ changedFunctions: base.changedFunctions ?? [],
590
+ changedVariables: base.changedVariables ?? [],
454
591
  }
455
592
  }
456
593
 
@@ -37,6 +37,7 @@ export type DiffManifest = {
37
37
  activeDiffId: string | null
38
38
  pendingInstruction: string | null
39
39
  workStartedAt: string | null
40
+ stepByStep: boolean
40
41
  createdAt: string
41
42
  updatedAt: string
42
43
  diffs: DiffEntry[]
@@ -52,7 +53,10 @@ export function isSessionConnected(
52
53
  waiterIds?: Set<string>,
53
54
  ): boolean
54
55
  export function listStoredSessionIds(dataDir: string): string[]
55
- export function listOpenSessionIds(dataDir: string): string[]
56
+ export function listOpenSessionIds(
57
+ dataDir: string,
58
+ waiterIds?: Set<string>,
59
+ ): string[]
56
60
  export function discardInactiveDiffSessions(
57
61
  dataDir: string,
58
62
  targetRoot?: string | null,
@@ -127,6 +131,18 @@ export function reportPlan(
127
131
  stepTitles: string[]
128
132
  },
129
133
  ): DiffManifest
134
+ export function isStepByStep(manifest: DiffManifest | null | undefined): boolean
135
+ export function autoAdvance(
136
+ dataDir: string,
137
+ sessionId: string,
138
+ targetRoot?: string | null,
139
+ ): DiffManifest | null
140
+ export function setStepByStep(
141
+ dataDir: string,
142
+ sessionId: string,
143
+ enabled: boolean,
144
+ targetRoot?: string | null,
145
+ ): DiffManifest
130
146
  export function invokeStep(
131
147
  dataDir: string,
132
148
  sessionId: string,
@@ -138,6 +154,7 @@ export function sessionIntent(
138
154
  sessionId: string,
139
155
  knownFileIds?: string[],
140
156
  selectedDiffId?: string,
157
+ waiterIds?: Set<string>,
141
158
  ): Record<string, unknown> | null
142
159
  export function resolveTargetFile(
143
160
  targetRoot: string,
@@ -1,10 +1,10 @@
1
1
  import fs from 'node:fs'
2
- import os from 'node:os'
3
2
  import path from 'node:path'
4
3
  import { spawnSync } from 'node:child_process'
5
4
  import {
6
5
  accumulatePatchAdditions,
7
6
  applyUnifiedPatch,
7
+ applyUnifiedPatchToContents,
8
8
  collectCreateFolders,
9
9
  extractPatchImports,
10
10
  foldersFromFileIds,
@@ -13,6 +13,7 @@ import {
13
13
 
14
14
  const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
15
15
  const CONNECTED_TTL_MS = 15_000
16
+ const STALLED_WAIT_MS = 2_000
16
17
 
17
18
  export function assertSessionId(value) {
18
19
  if (typeof value !== 'string' || !SESSION_ID.test(value) || value === '.' || value === '..') {
@@ -170,6 +171,13 @@ function isGeneratingPhase(phase) {
170
171
  return phase === 'preparing' || phase === 'working' || phase === 'replanning'
171
172
  }
172
173
 
174
+ function isStalledWorking(manifest, waiterIds, sessionId, now = Date.now()) {
175
+ if (manifest.phase !== 'working') return false
176
+ if (!waiterIds.has(sessionId)) return false
177
+ const started = Date.parse(manifest.workStartedAt)
178
+ return Number.isFinite(started) && now - started >= STALLED_WAIT_MS
179
+ }
180
+
173
181
  export function isSessionConnected(
174
182
  dataDir,
175
183
  sessionId,
@@ -216,16 +224,15 @@ export function listStoredSessionIds(dataDir) {
216
224
  return [...ids]
217
225
  }
218
226
 
219
- export function listOpenSessionIds(dataDir) {
227
+ export function listOpenSessionIds(dataDir, waiterIds = waiterSessionIds()) {
220
228
  const root = diffSessionsRoot(dataDir)
221
229
  if (!fs.existsSync(root)) return []
222
- const waiters = waiterSessionIds()
223
230
  const sessions = []
224
231
  for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
225
232
  if (!entry.isDirectory()) continue
226
233
  try {
227
234
  const sessionId = assertSessionId(entry.name)
228
- if (!isSessionConnected(dataDir, sessionId, waiters)) continue
235
+ if (!isSessionConnected(dataDir, sessionId, waiterIds)) continue
229
236
  const manifest = readManifest(dataDir, sessionId)
230
237
  if (!manifest) continue
231
238
  sessions.push({
@@ -246,8 +253,11 @@ export function listOpenSessionIds(dataDir) {
246
253
  }
247
254
 
248
255
  export function listSessionIntents(dataDir, knownFileIds = []) {
249
- return listOpenSessionIds(dataDir)
250
- .map((sessionId) => sessionIntent(dataDir, sessionId, knownFileIds))
256
+ const waiters = waiterSessionIds()
257
+ return listOpenSessionIds(dataDir, waiters)
258
+ .map((sessionId) =>
259
+ sessionIntent(dataDir, sessionId, knownFileIds, undefined, waiters),
260
+ )
251
261
  .filter(Boolean)
252
262
  }
253
263
 
@@ -313,6 +323,7 @@ export function readManifest(dataDir, sessionId) {
313
323
  value.pendingInstruction ??= null
314
324
  value.workStartedAt ??= null
315
325
  }
326
+ if (typeof value.stepByStep !== 'boolean') value.stepByStep = true
316
327
  return value
317
328
  }
318
329
 
@@ -417,7 +428,13 @@ export function previewPatchChain(patches, knownFileIds = []) {
417
428
  }
418
429
  }
419
430
 
420
- export function sessionIntent(dataDir, sessionId, knownFileIds = [], selectedDiffId) {
431
+ export function sessionIntent(
432
+ dataDir,
433
+ sessionId,
434
+ knownFileIds = [],
435
+ selectedDiffId,
436
+ waiterIds = waiterSessionIds(),
437
+ ) {
421
438
  const manifest = readManifest(dataDir, sessionId)
422
439
  if (!manifest) return null
423
440
  const selectedId = selectedDiffId || manifest.activeDiffId
@@ -467,6 +484,7 @@ export function sessionIntent(dataDir, sessionId, knownFileIds = [], selectedDif
467
484
  feature: manifest.feature,
468
485
  steps: manifest.steps,
469
486
  step: activeView ? manifest.currentStep : selected?.step ?? manifest.currentStep,
487
+ stepByStep: isStepByStep(manifest),
470
488
  reason: activeView ? currentPlanStep?.title ?? null : selected?.title ?? null,
471
489
  sessionId,
472
490
  diffId: selected?.id ?? null,
@@ -485,6 +503,7 @@ export function sessionIntent(dataDir, sessionId, knownFileIds = [], selectedDif
485
503
  manifest.phase === 'preparing' ||
486
504
  manifest.phase === 'working' ||
487
505
  manifest.phase === 'replanning',
506
+ stalledWait: isStalledWorking(manifest, waiterIds, sessionId),
488
507
  creationMode: manifest.phase === 'blueprint' && ownsBlueprintLock,
489
508
  canEnterBlueprint,
490
509
  blueprintSessionId,
@@ -633,25 +652,47 @@ export function materializeDiff(dataDir, targetRoot, sessionId, diffId) {
633
652
  return manifest
634
653
  }
635
654
 
636
- function withSessionReplay(dataDir, sessionId, targetRoot, patches, action) {
637
- const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'visual-coder-chain-'))
638
- try {
639
- fs.cpSync(targetRoot, temporary, { recursive: true })
640
- restoreBaseline(dataDir, sessionId, temporary)
641
- for (const patchText of patches) applyUnifiedPatch(patchText, temporary)
642
- return action(temporary)
643
- } finally {
644
- fs.rmSync(temporary, { recursive: true, force: true })
655
+ function loadReplayContents(dataDir, sessionId, targetRoot, patches) {
656
+ const baseline = readBaseline(dataDir, sessionId)
657
+ const paths = sessionPaths(dataDir, sessionId)
658
+ const fileIds = new Set(Object.keys(baseline.files))
659
+ for (const patchText of patches) {
660
+ for (const entry of parseUnifiedPatch(patchText).entries) {
661
+ fileIds.add(entry.id)
662
+ }
645
663
  }
664
+
665
+ const files = new Map()
666
+ for (const fileId of fileIds) {
667
+ const info = baseline.files[fileId]
668
+ if (info) {
669
+ if (!info.existed) continue
670
+ const stored = resolveTargetFile(paths.baselineFiles, fileId).absolute
671
+ if (fs.existsSync(stored) && fs.statSync(stored).isFile()) {
672
+ files.set(fileId, fs.readFileSync(stored, 'utf8'))
673
+ }
674
+ continue
675
+ }
676
+ const { absolute } = resolveTargetFile(targetRoot, fileId)
677
+ if (fs.existsSync(absolute) && fs.statSync(absolute).isFile()) {
678
+ files.set(fileId, fs.readFileSync(absolute, 'utf8'))
679
+ }
680
+ }
681
+ return files
646
682
  }
647
683
 
648
684
  export function validateContinuation(dataDir, manifest, targetRoot, patchText) {
649
685
  const prior = manifest.diffs
650
686
  .filter((entry) => entry.status !== 'rejected')
651
687
  .map((entry) => readDiff(dataDir, manifest.sessionId, entry))
652
- withSessionReplay(dataDir, manifest.sessionId, targetRoot, [...prior, patchText], () =>
653
- undefined,
688
+ const patches = [...prior, patchText]
689
+ const files = loadReplayContents(
690
+ dataDir,
691
+ manifest.sessionId,
692
+ targetRoot,
693
+ patches,
654
694
  )
695
+ for (const next of patches) applyUnifiedPatchToContents(files, next)
655
696
  }
656
697
 
657
698
  export function inspectTargetFile(
@@ -686,6 +727,33 @@ function featureName(value) {
686
727
  return trimmed
687
728
  }
688
729
 
730
+ export function isStepByStep(manifest) {
731
+ return manifest?.stepByStep !== false
732
+ }
733
+
734
+ export function autoAdvance(dataDir, sessionId, targetRoot = null) {
735
+ const manifest = readManifest(dataDir, sessionId)
736
+ if (!manifest || isStepByStep(manifest)) return manifest
737
+ if (manifest.phase === 'plan_ready') {
738
+ return invokeStep(dataDir, sessionId, manifest.currentStep, targetRoot)
739
+ }
740
+ if (manifest.phase === 'review') {
741
+ const active = manifest.diffs.at(-1)
742
+ if (!active || active.status !== 'pending') return manifest
743
+ if (active.step >= manifest.steps.length) return manifest
744
+ return invokeStep(dataDir, sessionId, active.step + 1, targetRoot)
745
+ }
746
+ return manifest
747
+ }
748
+
749
+ export function setStepByStep(dataDir, sessionId, enabled, targetRoot = null) {
750
+ const manifest = requireManifest(dataDir, sessionId)
751
+ manifest.stepByStep = Boolean(enabled)
752
+ writeManifest(dataDir, manifest)
753
+ if (isStepByStep(manifest)) return manifest
754
+ return autoAdvance(dataDir, sessionId, targetRoot)
755
+ }
756
+
689
757
  export function startSession(dataDir, input) {
690
758
  const sessionId = assertSessionId(input.sessionId)
691
759
  clearStoppedMarker(dataDir, sessionId)
@@ -703,6 +771,7 @@ export function startSession(dataDir, input) {
703
771
  steps: [],
704
772
  status: 'active',
705
773
  phase: 'blueprint_ask',
774
+ stepByStep: true,
706
775
  currentStep: 1,
707
776
  activeDiffId: null,
708
777
  pendingInstruction: null,
@@ -802,6 +871,7 @@ export function reportPlan(dataDir, input) {
802
871
  steps: [],
803
872
  status: 'active',
804
873
  phase: 'preparing',
874
+ stepByStep: true,
805
875
  currentStep: 1,
806
876
  activeDiffId: null,
807
877
  pendingInstruction: null,
@@ -818,7 +888,7 @@ export function reportPlan(dataDir, input) {
818
888
  manifest.workStartedAt = null
819
889
  writeManifest(dataDir, manifest)
820
890
  focusSession(dataDir, sessionId)
821
- return manifest
891
+ return autoAdvance(dataDir, sessionId)
822
892
  }
823
893
 
824
894
  if (existing.phase !== 'replanning') {
@@ -835,7 +905,7 @@ export function reportPlan(dataDir, input) {
835
905
  existing.workStartedAt = null
836
906
  writeManifest(dataDir, existing)
837
907
  focusSession(dataDir, sessionId)
838
- return existing
908
+ return autoAdvance(dataDir, sessionId)
839
909
  }
840
910
 
841
911
  export function invokeStep(dataDir, sessionId, step, targetRoot = null) {
@@ -939,7 +1009,14 @@ export function appendDiff(dataDir, targetRoot, input) {
939
1009
  writeManifest(dataDir, manifest)
940
1010
  materializeDiff(dataDir, targetRoot, sessionId, id)
941
1011
  focusSession(dataDir, sessionId)
942
- return { manifest, entry }
1012
+ const advanced = autoAdvance(dataDir, sessionId, targetRoot)
1013
+ if (!advanced) {
1014
+ throw new Error(`Session ${sessionId} disappeared after publishing a diff`)
1015
+ }
1016
+ return {
1017
+ manifest: advanced,
1018
+ entry: advanced.diffs.find((item) => item.id === id) ?? entry,
1019
+ }
943
1020
  }
944
1021
 
945
1022
  function pendingActive(manifest, diffId) {
@@ -976,13 +1053,12 @@ export function continueDiff(dataDir, targetRoot, sessionId, diffId) {
976
1053
  writeManifest(dataDir, manifest)
977
1054
  finalizeFinishedSession(dataDir, sessionId)
978
1055
  return manifest
979
- } else {
980
- manifest.currentStep = active.step + 1
981
- manifest.phase = 'plan_ready'
982
- manifest.workStartedAt = null
983
1056
  }
1057
+ manifest.currentStep = active.step + 1
1058
+ manifest.phase = 'plan_ready'
1059
+ manifest.workStartedAt = null
984
1060
  writeManifest(dataDir, manifest)
985
- return manifest
1061
+ return autoAdvance(dataDir, sessionId, targetRoot)
986
1062
  }
987
1063
 
988
1064
  export function requestReplan(dataDir, sessionId, diffId, instruction) {