@vgai/sdk 0.5.4 → 0.5.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.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@vgai/sdk",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "Apache-2.0",
5
- "version": "0.5.4",
5
+ "version": "0.5.5",
6
6
  "type": "module",
7
7
  "repository": {
8
8
  "type": "git",
@@ -18,16 +18,18 @@
18
18
  "exports": {
19
19
  ".": "./src/index.ts",
20
20
  "./account": "./src/account.ts",
21
+ "./build-discipline": "./src/project/build-discipline.ts",
21
22
  "./generations": "./src/generations.ts",
22
23
  "./mcp-stdio": "./src/mcp/mcp-stdio-server.ts",
23
24
  "./project-tool-catalog": "./src/project-tool-catalog.ts",
24
25
  "./project-inspection-node": "./src/project/inspection-node.ts",
25
26
  "./registry": "./src/registry.ts",
27
+ "./session-journal": "./src/project/session-journal.ts",
26
28
  "./tools": "./src/tools.ts"
27
29
  },
28
30
  "dependencies": {
29
31
  "@modelcontextprotocol/sdk": "^1.30.0",
30
- "@vgai/engine": "0.5.4",
32
+ "@vgai/engine": "0.5.5",
31
33
  "playwright": "^1.58.2",
32
34
  "zod": "^4.3.6"
33
35
  }
@@ -0,0 +1,573 @@
1
+ /**
2
+ * The build-discipline tripwires: "how long has this work been sitting
3
+ * uncommitted?" and "has this session ever run the game at all?", plus the
4
+ * cheap project reads behind them.
5
+ *
6
+ * WHY THIS LIVES IN THE SDK. It started in the CLI, wired to `vgai status`
7
+ * alone — and a measured 17-minute blind build ran the editor, `playtest` and
8
+ * `eval` while invoking `vgai status` ZERO times. The mechanisms were right;
9
+ * the delivery assumption ("a building agent polls status constantly") was
10
+ * false. Routing the SAME banners through the surfaces a build actually
11
+ * crosses means three processes must compose them — the CLI, the editor dev
12
+ * server, and a project's own `npm run playtest` — so the text and the
13
+ * thresholds move to the layer all three already depend on (this package: CLI
14
+ * -> SDK -> engine, never the reverse). There is exactly ONE owner of the
15
+ * wording and ONE owner of the numbers; every channel calls it.
16
+ *
17
+ * COMMIT CADENCE. Measured failure (blind-probe audit, three consecutive
18
+ * probes): each quoted the project docs' "one commit per slice" bar back at
19
+ * the reader, and each then shipped its ENTIRE build as one end-of-run commit.
20
+ * The rule was read, understood, agreed with, and violated, because nothing
21
+ * fired DURING the violation: prose is only ever read before the work, and a
22
+ * commit that never happens produces no event.
23
+ *
24
+ * What it measures is NOT "time since the last commit" — a repo whose last
25
+ * commit is three days old but whose first edit landed two minutes ago is not
26
+ * behind on anything. The age of the CURRENT uncommitted batch is when its
27
+ * oldest still-dirty file was written, floored at the last commit (work cannot
28
+ * have been uncommitted before the commit that would have contained it). That
29
+ * floor is what stops a long-parked dirty file from crying wolf forever.
30
+ *
31
+ * LIVE EVIDENCE. Measured failure, same audit: the final source edits of a
32
+ * build shipped with zero runtime evidence behind them — eleven turns of code
33
+ * nothing ever executed, handed off as finished, with every green check in the
34
+ * terminal agreeing. Nothing in the toolchain could disagree: typecheck, tests
35
+ * and validators all answer questions about the SOURCE, and the one question
36
+ * left is whether the source was ever run. `staleEvidenceBanner` asks it of
37
+ * the code on disk; `unplayedSessionBanner` asks it of the session's own clock,
38
+ * which is the only one that can see the OPENING stretch of a build (a session
39
+ * that has never played has no evidence for the source to be stale against).
40
+ *
41
+ * None of this is a poller and none of it is a hook: every function here is
42
+ * either pure or a handful of `git`/`stat` reads made on an event the caller
43
+ * already handles.
44
+ */
45
+
46
+ import { spawnSync } from 'node:child_process';
47
+ import { existsSync, readdirSync, statSync } from 'node:fs';
48
+ import { join } from 'node:path';
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Shared vocabulary
52
+ // ---------------------------------------------------------------------------
53
+
54
+ /**
55
+ * How loud a tripwire is right now.
56
+ *
57
+ * Three steps, not a number that grows: a single line at `notice` reads as
58
+ * information, the block at `loud` reads as a problem, and the difference is
59
+ * what makes "45m" land differently from "3m". It is also what an event-driven
60
+ * channel debounces on — see `advanceTripwireGate`.
61
+ */
62
+ export type TripwireTier = 'silent' | 'notice' | 'loud';
63
+
64
+ const TIER_RANK: Record<TripwireTier, number> = { silent: 0, notice: 1, loud: 2 };
65
+
66
+ /** `12m`, `1h 06m` — a duration a reader can compare at a glance. */
67
+ function formatElapsed(ms: number): string {
68
+ const totalMinutes = Math.floor(ms / 60_000);
69
+ if (totalMinutes < 60) return `${totalMinutes}m`;
70
+ const hours = Math.floor(totalMinutes / 60);
71
+ return `${hours}h ${String(totalMinutes % 60).padStart(2, '0')}m`;
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Commit cadence
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /**
79
+ * The escalation steps, in ms.
80
+ *
81
+ * NOTICE at 10 minutes: roughly one honest slice. Early enough that the
82
+ * warning can still change the outcome (the measured probes had already
83
+ * finished several committable slices by then), late enough that an ordinary
84
+ * in-progress edit never sees it.
85
+ *
86
+ * LOUD at 25 minutes: the measured defect was a 26-minute single-commit build,
87
+ * so the loud step has to land BEFORE that, not at it — a banner that first
88
+ * appears at the moment the mistake completes has reported history, not
89
+ * prevented anything.
90
+ */
91
+ const CADENCE_NOTICE_MS = 10 * 60_000;
92
+ const CADENCE_LOUD_MS = 25 * 60_000;
93
+
94
+ /** Bound on the per-file stat fan-out. A batch this large is already far past
95
+ * every threshold below, so the extra files cannot change the verdict. */
96
+ const MAX_DIRTY_PATHS_DATED = 300;
97
+
98
+ /** The current uncommitted batch: how old it is, and how much is in it. */
99
+ export interface UncommittedWork {
100
+ /** ms since the oldest still-dirty change in this batch (floored at the last commit). */
101
+ readonly ageMs: number;
102
+ /** Number of dirty paths git reported (tracked modifications + untracked). */
103
+ readonly fileCount: number;
104
+ }
105
+
106
+ /**
107
+ * Age of the current uncommitted batch — pure, so the floor rule is testable
108
+ * as data-in/answer-out.
109
+ *
110
+ * `null` when there is nothing to date. `lastCommitAtMs` is `null` on an
111
+ * unborn HEAD (no commit to floor against, so the file mtime stands alone).
112
+ */
113
+ export function uncommittedWorkAge(
114
+ now: number,
115
+ lastCommitAtMs: number | null,
116
+ oldestDirtyMtimeMs: number | null,
117
+ ): number | null {
118
+ if (oldestDirtyMtimeMs === null) return null;
119
+ const startedAt =
120
+ lastCommitAtMs === null ? oldestDirtyMtimeMs : Math.max(lastCommitAtMs, oldestDirtyMtimeMs);
121
+ return Math.max(0, now - startedAt);
122
+ }
123
+
124
+ /** Which step the batch has reached — the ONE place the cadence thresholds are
125
+ * compared, so every channel (status, playtest, dev server, eval) agrees. */
126
+ export function commitCadenceTier(work: UncommittedWork | null): TripwireTier {
127
+ if (!work || work.ageMs < CADENCE_NOTICE_MS) return 'silent';
128
+ return work.ageMs < CADENCE_LOUD_MS ? 'notice' : 'loud';
129
+ }
130
+
131
+ /**
132
+ * The ONE sentence this tripwire says in a single line.
133
+ *
134
+ * Shared by the notice step of `commitCadenceBanner` and by
135
+ * `commitCadenceNotice`, so a channel that cannot afford a block still says
136
+ * exactly what the block says — never a second wording of the same rule.
137
+ */
138
+ function cadenceLine(work: UncommittedWork): string {
139
+ return (
140
+ `uncommitted work for ${formatElapsed(work.ageMs)} (${work.fileCount} file(s)) — ` +
141
+ 'the bar is one commit per slice; commit the slice that already works'
142
+ );
143
+ }
144
+
145
+ /**
146
+ * The escalating stderr line/banner for uncommitted work — pure, driven
147
+ * directly by a test. `null` below NOTICE and whenever there is no batch at
148
+ * all.
149
+ */
150
+ export function commitCadenceBanner(work: UncommittedWork | null): string | null {
151
+ const tier = commitCadenceTier(work);
152
+ if (!work || tier === 'silent') return null;
153
+ if (tier === 'notice') return cadenceLine(work);
154
+ const age = formatElapsed(work.ageMs);
155
+ const files = `${work.fileCount} file(s)`;
156
+ return [
157
+ '================================================================',
158
+ ` UNCOMMITTED WORK FOR ${age.toUpperCase()}`,
159
+ '================================================================',
160
+ ` ${files} have been dirty for ${age} with no commit behind them.`,
161
+ '',
162
+ ' The bar is ONE COMMIT PER SLICE. A build that lands as a single',
163
+ ' end-of-run commit cannot be reviewed, bisected, or partially',
164
+ ' recovered when a later step goes wrong — and a build that reaches',
165
+ ' this banner is on exactly that path.',
166
+ '',
167
+ ' Commit what already works now, then keep going:',
168
+ ' git add <the files of the slice you finished>',
169
+ ' git commit -m "<the slice>"',
170
+ '================================================================',
171
+ ].join('\n');
172
+ }
173
+
174
+ /**
175
+ * The same tripwire as ONE line, at every step including the loud one.
176
+ *
177
+ * For channels whose output is parsed rather than read — `vgai eval` prints a
178
+ * game's own JSON, and a fifteen-line block dropped beside it corrupts more
179
+ * than it warns. Same sentence as the notice step of the banner (`cadenceLine`
180
+ * is the single source); the only thing dropped is the block.
181
+ */
182
+ export function commitCadenceNotice(work: UncommittedWork | null): string | null {
183
+ if (!work || commitCadenceTier(work) === 'silent') return null;
184
+ return cadenceLine(work);
185
+ }
186
+
187
+ /**
188
+ * The dirty paths in a `git status --porcelain -z` payload — pure, because the
189
+ * record shape is the one part of this that is easy to get quietly wrong.
190
+ *
191
+ * `-z` rather than plain porcelain because porcelain QUOTES paths containing
192
+ * special characters, and a quoted path stats as nothing. Records are
193
+ * `XY <path>`; a rename adds a second, prefix-less chunk holding the SOURCE
194
+ * path, which the shape test skips — the destination is the file on disk.
195
+ * Paths are relative to the repository root, not to cwd.
196
+ */
197
+ export function dirtyPathsFromPorcelain(porcelain: string): string[] {
198
+ const paths: string[] = [];
199
+ for (const chunk of porcelain.split('\0')) {
200
+ const match = /^(..) (.+)$/.exec(chunk);
201
+ if (match?.[2]) paths.push(match[2]);
202
+ }
203
+ return paths;
204
+ }
205
+
206
+ /** Runs git in `cwd`. `null` for ANY failure — git missing, not a repository,
207
+ * unborn HEAD, non-zero exit — because each of those means "this project
208
+ * cannot be asked the question", not "this project is behind". */
209
+ function git(cwd: string, args: string[]): string | null {
210
+ const result = spawnSync('git', args, { cwd, encoding: 'utf-8' });
211
+ if (result.error || result.status !== 0) return null;
212
+ return result.stdout ?? '';
213
+ }
214
+
215
+ /**
216
+ * Read the current uncommitted batch for `projectRoot`.
217
+ *
218
+ * `null` — say nothing — when there is no project, no git work tree, or a
219
+ * clean tree. Two `git` invocations plus one `stat` per dirty path (capped),
220
+ * made on a caller's own event (a status request, a finished playtest run, a
221
+ * save the dev server already validated) — never on a timer.
222
+ */
223
+ export function readUncommittedWork(
224
+ projectRoot: string | null,
225
+ now: number = Date.now(),
226
+ ): UncommittedWork | null {
227
+ if (!projectRoot) return null;
228
+ const topLevel = git(projectRoot, ['rev-parse', '--show-toplevel'])?.trim();
229
+ if (!topLevel) return null;
230
+
231
+ // The `.` pathspec is what scopes the question to this project when it sits
232
+ // inside a larger repo.
233
+ const porcelain = git(projectRoot, ['status', '--porcelain', '-z', '--', '.']);
234
+ if (porcelain === null) return null;
235
+ const paths = dirtyPathsFromPorcelain(porcelain);
236
+ if (paths.length === 0) return null;
237
+
238
+ let oldest: number | null = null;
239
+ for (const relative of paths.slice(0, MAX_DIRTY_PATHS_DATED)) {
240
+ try {
241
+ const mtime = statSync(join(topLevel, relative)).mtimeMs;
242
+ if (oldest === null || mtime < oldest) oldest = mtime;
243
+ } catch {
244
+ /* a deleted path dates nothing */
245
+ }
246
+ }
247
+
248
+ const committedAt = git(projectRoot, ['log', '-1', '--format=%ct'])?.trim();
249
+ const lastCommitAtMs =
250
+ committedAt && /^\d+$/.test(committedAt) ? Number.parseInt(committedAt, 10) * 1000 : null;
251
+
252
+ const ageMs = uncommittedWorkAge(now, lastCommitAtMs, oldest);
253
+ if (ageMs === null) return null;
254
+ return { ageMs, fileCount: paths.length };
255
+ }
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // Live evidence
259
+ // ---------------------------------------------------------------------------
260
+
261
+ /** Directories never walked when dating a project's source: build output,
262
+ * dependencies, and the evidence directories themselves (a run's artifacts
263
+ * must never read as a source change). */
264
+ const SKIP_DIRS = new Set([
265
+ 'node_modules',
266
+ '.git',
267
+ '.vgai',
268
+ '.agents',
269
+ '.claude',
270
+ '.github',
271
+ 'dist',
272
+ 'dist-server',
273
+ 'build',
274
+ 'logs',
275
+ 'coverage',
276
+ '.turbo',
277
+ '.vite',
278
+ 'test-results',
279
+ 'playwright-report',
280
+ ]);
281
+
282
+ /** Extensions that count as project source for dating purposes. */
283
+ const SOURCE_EXT = ['.ts', '.tsx', '.js', '.jsx', '.json', '.css', '.html'];
284
+
285
+ /** Bound on the walk. A project big enough to exceed it is one where the
286
+ * answer is dominated by the first few thousand files anyway, and a status
287
+ * poll must stay cheap whatever it is pointed at. */
288
+ const MAX_FILES_WALKED = 4000;
289
+
290
+ /**
291
+ * The newest mtime (epoch ms) among the project's own source files, or `null`
292
+ * when there are none to date.
293
+ *
294
+ * `src/` plus the manifest and package.json — the files whose change means
295
+ * "the build is different now". Deliberately NOT the whole tree: a screenshot
296
+ * landing in `.vgai/`, a log line, or a `node_modules` touch is not a source
297
+ * change, and treating it as one would make the banner cry wolf on its own
298
+ * evidence.
299
+ */
300
+ export function newestSourceMtime(projectRoot: string): number | null {
301
+ let newest: number | null = null;
302
+ let walked = 0;
303
+
304
+ const consider = (file: string): void => {
305
+ try {
306
+ const at = statSync(file).mtimeMs;
307
+ if (newest === null || at > newest) newest = at;
308
+ } catch {
309
+ /* a file that vanished mid-walk dates nothing */
310
+ }
311
+ };
312
+
313
+ const isDirectory = (file: string): boolean | null => {
314
+ try {
315
+ return statSync(file).isDirectory();
316
+ } catch {
317
+ return null; // vanished mid-walk
318
+ }
319
+ };
320
+
321
+ const walk = (dir: string): void => {
322
+ let entries: string[];
323
+ try {
324
+ entries = readdirSync(dir);
325
+ } catch {
326
+ return;
327
+ }
328
+ for (const entry of entries) {
329
+ if (walked >= MAX_FILES_WALKED) return;
330
+ if (SKIP_DIRS.has(entry)) continue;
331
+ const full = join(dir, entry);
332
+ const isDir = isDirectory(full);
333
+ if (isDir === null) continue;
334
+ if (isDir) walk(full);
335
+ else if (SOURCE_EXT.some((ext) => entry.endsWith(ext))) {
336
+ walked += 1;
337
+ consider(full);
338
+ }
339
+ }
340
+ };
341
+
342
+ walk(join(projectRoot, 'src'));
343
+ for (const file of ['vgai.project.json', 'package.json']) {
344
+ const full = join(projectRoot, file);
345
+ if (existsSync(full)) consider(full);
346
+ }
347
+ return newest;
348
+ }
349
+
350
+ /**
351
+ * The newest mtime (epoch ms) among this project's LIVE-EVIDENCE artifacts, or
352
+ * `null` when the game has never run.
353
+ *
354
+ * The two artifacts are the same pair the idiom checker's "has this ever been
355
+ * played" rule reads, for the same reason: both are written by the tools
356
+ * themselves while a real browser runs the real game, so neither can be
357
+ * produced by intending to play.
358
+ * - `logs/play-*.jsonl` — one per Play session, opened by the editor server.
359
+ * - `.vgai/last-run/**` — what a bot run leaves behind (screenshots, and the
360
+ * `playtest.json` verdict `npm run playtest` files).
361
+ */
362
+ export function newestEvidenceMtime(projectRoot: string): number | null {
363
+ let newest: number | null = null;
364
+ const consider = (file: string): void => {
365
+ try {
366
+ const at = statSync(file).mtimeMs;
367
+ if (newest === null || at > newest) newest = at;
368
+ } catch {
369
+ /* unreadable: not evidence */
370
+ }
371
+ };
372
+
373
+ const logsDir = join(projectRoot, 'logs');
374
+ try {
375
+ for (const entry of readdirSync(logsDir)) {
376
+ if (entry.startsWith('play-') && entry.endsWith('.jsonl')) consider(join(logsDir, entry));
377
+ }
378
+ } catch {
379
+ /* no logs dir */
380
+ }
381
+
382
+ const runDir = join(projectRoot, '.vgai', 'last-run');
383
+ try {
384
+ for (const entry of readdirSync(runDir)) consider(join(runDir, entry));
385
+ } catch {
386
+ /* no run dir */
387
+ }
388
+ return newest;
389
+ }
390
+
391
+ /** The later of two optional instants — how a caller combines the two walks
392
+ * above into one "when did anything about this project last change" number. */
393
+ export function latestOf(a: number | null, b: number | null): number | null {
394
+ if (a === null) return b;
395
+ if (b === null) return a;
396
+ return Math.max(a, b);
397
+ }
398
+
399
+ /**
400
+ * The loud banner for SOURCE THAT HAS NEVER RUN — pure so it can be driven
401
+ * directly by a test.
402
+ *
403
+ * `null` in the two honest silences:
404
+ * - a project with no datable source (nothing to be stale);
405
+ * - source no newer than the newest evidence (the running build IS this code).
406
+ *
407
+ * NOT silent when there is no evidence at all: a project that has never run is
408
+ * the strongest form of the thing this reports, not an exemption from it.
409
+ */
410
+ export function staleEvidenceBanner(
411
+ newestSource: number | null,
412
+ newestEvidence: number | null,
413
+ ): string | null {
414
+ if (newestSource === null) return null;
415
+ if (newestEvidence !== null && newestEvidence >= newestSource) return null;
416
+ const gap =
417
+ newestEvidence === null
418
+ ? ' no live evidence exists at all — no logs/play-*.jsonl, no .vgai/last-run artifacts.'
419
+ : ` newest source: ${new Date(newestSource).toISOString()}\n` +
420
+ ` newest live evidence: ${new Date(newestEvidence).toISOString()}`;
421
+ return [
422
+ '================================================================',
423
+ ' SOURCE CHANGED SINCE LAST LIVE EVIDENCE',
424
+ '================================================================',
425
+ gap,
426
+ '',
427
+ ' The shipped build has never run. Typecheck, tests and validators all',
428
+ ' answer questions about the source; whether it WORKS is a question only',
429
+ ' running it can answer — and the pixel-only failures (blank canvas,',
430
+ ' invisible mesh, camera inside the geometry) are invisible to every one',
431
+ ' of them.',
432
+ '',
433
+ ' Play or playtest before claiming it works: `vgai play`, then look, or',
434
+ ' `npm run playtest` for the bot proof.',
435
+ '================================================================',
436
+ ].join('\n');
437
+ }
438
+
439
+ /**
440
+ * The escalation steps for "this session has never played", in ms.
441
+ *
442
+ * NOTICE at 5 minutes: the opening minutes of a session are legitimately spent
443
+ * reading the brief and the scaffold, so a line before that would fire on
444
+ * every single session and be tuned out by the second one.
445
+ *
446
+ * LOUD at 15 minutes: the measured blind stretch was 11 minutes, so the loud
447
+ * step lands just past it rather than at the end of a build — by 15 minutes a
448
+ * session has authored real gameplay it has never once watched run.
449
+ */
450
+ const UNPLAYED_NOTICE_MS = 5 * 60_000;
451
+ const UNPLAYED_LOUD_MS = 15 * 60_000;
452
+
453
+ /**
454
+ * Which step the "never played" clock has reached — the ONE place the unplayed
455
+ * thresholds are compared.
456
+ *
457
+ * `silent` in the honest silences: no session serving this project (no clock to
458
+ * run), or evidence at least as new as the session start (this session HAS
459
+ * played, and how long ago is `staleEvidenceBanner`'s question, not this one).
460
+ */
461
+ export function unplayedSessionTier(
462
+ sessionStartedAtMs: number | null,
463
+ newestEvidence: number | null,
464
+ now: number,
465
+ ): TripwireTier {
466
+ if (sessionStartedAtMs === null) return 'silent';
467
+ if (newestEvidence !== null && newestEvidence >= sessionStartedAtMs) return 'silent';
468
+ const servingForMs = now - sessionStartedAtMs;
469
+ if (servingForMs < UNPLAYED_NOTICE_MS) return 'silent';
470
+ return servingForMs < UNPLAYED_LOUD_MS ? 'notice' : 'loud';
471
+ }
472
+
473
+ /**
474
+ * The escalating line/banner for a session that has served this project
475
+ * without ever producing live-play evidence — pure, driven directly by a test.
476
+ *
477
+ * The evidence signal is the SAME pair `newestEvidenceMtime` walks (a Play
478
+ * session's `logs/play-*.jsonl`, a bot run's `.vgai/last-run/**`), so this
479
+ * banner and the staleness banner can never disagree about what counts as
480
+ * having run. Nothing new is instrumented on the game side: both artifacts are
481
+ * already written by the tools themselves while a real browser runs the real
482
+ * game, which is exactly why neither can be produced by intending to play.
483
+ */
484
+ export function unplayedSessionBanner(
485
+ sessionStartedAtMs: number | null,
486
+ newestEvidence: number | null,
487
+ now: number,
488
+ ): string | null {
489
+ const tier = unplayedSessionTier(sessionStartedAtMs, newestEvidence, now);
490
+ if (tier === 'silent' || sessionStartedAtMs === null) return null;
491
+ const elapsed = formatElapsed(now - sessionStartedAtMs);
492
+ if (tier === 'notice') {
493
+ return (
494
+ `unplayed for ${elapsed} — this editor session has never run the game; ` +
495
+ 'start it now (`vgai play`) rather than at the end'
496
+ );
497
+ }
498
+ return [
499
+ '================================================================',
500
+ ` ${elapsed.toUpperCase()} SERVING THIS PROJECT, NEVER ONCE PLAYED`,
501
+ '================================================================',
502
+ ' No Play session and no bot run has produced any live evidence since',
503
+ ' this editor session started.',
504
+ '',
505
+ ' Every gate that has passed so far answered a question about the',
506
+ ' SOURCE. The failures that only a running game shows — blank canvas,',
507
+ ' invisible mesh, camera inside the geometry, input that reaches',
508
+ ' nothing — are invisible to all of them, and the longer the first play',
509
+ ' is deferred the more work is stacked on top of an unverified base.',
510
+ '',
511
+ ' Play it now: `vgai play`, then look — or `npm run playtest` for the',
512
+ ' bot proof.',
513
+ '================================================================',
514
+ ].join('\n');
515
+ }
516
+
517
+ // ---------------------------------------------------------------------------
518
+ // The event-driven channel's gate
519
+ // ---------------------------------------------------------------------------
520
+
521
+ /**
522
+ * What an event-driven channel remembers between events.
523
+ *
524
+ * The editor dev server prints these banners on the save/validation event it
525
+ * ALREADY handles — no timer, no poller — and a save burst is dozens of those
526
+ * events in a second. Two things therefore have to be bounded: how often the
527
+ * (git + stat) reads run at all, and how often the same warning is repeated.
528
+ */
529
+ export interface TripwireGate {
530
+ /** When the reads last ran, or `null` if they never have. */
531
+ readonly evaluatedAtMs: number | null;
532
+ /** The highest tier announced since the tripwire last went quiet. */
533
+ readonly announced: TripwireTier;
534
+ }
535
+
536
+ /** A gate that has never evaluated and never announced — the initial state, and
537
+ * what a project switch resets to. */
538
+ export const IDLE_TRIPWIRE_GATE: TripwireGate = { evaluatedAtMs: null, announced: 'silent' };
539
+
540
+ /**
541
+ * Floor on how often the reads run, whatever the event rate.
542
+ *
543
+ * A minute is far finer than any threshold here (the earliest is 5 minutes),
544
+ * so it costs the banner no timeliness at all, while a 50-file save burst pays
545
+ * for at most one `git status` instead of fifty.
546
+ */
547
+ export const TRIPWIRE_MIN_EVAL_INTERVAL_MS = 60_000;
548
+
549
+ /** May this event pay for the reads, or has one already run recently enough? */
550
+ export function shouldEvaluateTripwires(gate: TripwireGate, now: number): boolean {
551
+ return gate.evaluatedAtMs === null || now - gate.evaluatedAtMs >= TRIPWIRE_MIN_EVAL_INTERVAL_MS;
552
+ }
553
+
554
+ /**
555
+ * Fold a freshly measured tier into the gate: print only on a CROSSING.
556
+ *
557
+ * `announce` is true exactly when the tier is higher than the highest one
558
+ * already announced, so a burst of saves at the same tier prints once, the
559
+ * escalation to `loud` prints again, and dropping back (a commit landed, a play
560
+ * happened) re-arms the gate for the next crossing. Debounce as a rank
561
+ * comparison rather than a wall-clock window: it is what "at most once per
562
+ * threshold crossing" literally means, and it needs no clock to be correct.
563
+ */
564
+ export function advanceTripwireGate(
565
+ gate: TripwireGate,
566
+ tier: TripwireTier,
567
+ now: number,
568
+ ): { readonly announce: boolean; readonly gate: TripwireGate } {
569
+ return {
570
+ announce: TIER_RANK[tier] > TIER_RANK[gate.announced],
571
+ gate: { evaluatedAtMs: now, announced: tier },
572
+ };
573
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * The editor session's console stream, as a STRUCTURED FILE an agent can read
3
+ * at any moment: `<project>/logs/editor-<sessionstamp>.jsonl`.
4
+ *
5
+ * WHY. Agents do not watch terminals; they read files. Every discipline signal
6
+ * the editor server already produces — a save that failed validation, a
7
+ * build-discipline tripwire crossing (`build-discipline.ts`), the session's own
8
+ * lifecycle — was delivered ONLY as a terminal print, which in detach mode goes
9
+ * to a raw capture nobody parses and in foreground scrolls past whoever was not
10
+ * looking. Same measured shape as the tripwires' own origin story: the
11
+ * mechanism was right and the delivery assumption was false.
12
+ *
13
+ * SO THE JOURNAL IS THE RECORD AND THE PRINTS ARE ITS RENDERERS. Every caller
14
+ * emits the event here first and renders second, from one emit point, so no
15
+ * line can reach a terminal without a durable twin on disk. Nothing new is
16
+ * measured for the journal's sake: each event is a fact one of those surfaces
17
+ * was about to print anyway.
18
+ *
19
+ * IT LIVES BESIDE `logs/play-*.jsonl`, in the project, deliberately: that is
20
+ * the established idiom for "durable evidence this session produced", the
21
+ * scaffold already gitignores `logs/`, and `build-discipline.ts` already skips
22
+ * `logs/` when dating source (so a journal line can never read as a source
23
+ * change and make a tripwire cry wolf on its own output).
24
+ *
25
+ * SCHEMA-LIGHT ON PURPOSE. One discriminated union, one writer, no zod: nothing
26
+ * reads a journal back through a validator — an agent reads it, and `JSON.parse`
27
+ * per line is the whole contract. Append-only, one object per line, no levels,
28
+ * no transports, no config.
29
+ *
30
+ * BOUNDS. Same rule the play logs follow (`/__editor/log-session`): prune to the
31
+ * newest `MAX_JOURNAL_FILES` when a new one is opened. Within a session the file
32
+ * grows unbounded, which is what an append-only record means.
33
+ */
34
+
35
+ import { appendFileSync, mkdirSync, readdirSync, unlinkSync } from 'node:fs';
36
+ import { join } from 'node:path';
37
+ import type { TripwireTier } from './build-discipline';
38
+
39
+ /** `editor-` + an ISO instant with `:`/`.` flattened + `.jsonl` — the same
40
+ * lexicographic-order-is-chronological-order shape `play-*.jsonl` uses. */
41
+ const PREFIX = 'editor-';
42
+ const SUFFIX = '.jsonl';
43
+
44
+ /** Journals kept per project. Matches the play logs' own `MAX_LOG_FILES`. */
45
+ const MAX_JOURNAL_FILES = 20;
46
+
47
+ /**
48
+ * One journal line's payload. `at` (ISO) is added by the writer, so a caller
49
+ * only ever describes WHAT happened.
50
+ *
51
+ * The tripwire arm reuses `build-discipline.ts`'s vocabulary verbatim — its
52
+ * `TripwireTier`, its tripwire names, and the same inputs its banners are
53
+ * computed from — so the JSONL and the printed banner can never disagree about
54
+ * what fired or how loud it was.
55
+ */
56
+ export type SessionJournalEvent =
57
+ /** This server began serving this project (the process's own start). */
58
+ | { readonly kind: 'session-started'; readonly project: string; readonly pid: number }
59
+ /** `POST /__editor/open-project` switched the live session's project. */
60
+ | {
61
+ readonly kind: 'project-opened';
62
+ readonly project: string;
63
+ readonly previousProject: string | null;
64
+ }
65
+ /** The session is going down (best-effort: a SIGKILL logs nothing). */
66
+ | { readonly kind: 'session-shutdown' }
67
+ /** A save-validation verdict — the same facts `runFileValidation` reports. */
68
+ | {
69
+ readonly kind: 'validation';
70
+ /** Project-relative, forward-slash. */
71
+ readonly path: string;
72
+ /** The validatable kind the server classified this file as. */
73
+ readonly fileKind: string;
74
+ readonly ok: boolean;
75
+ readonly errors?: readonly string[];
76
+ readonly warnings?: readonly string[];
77
+ }
78
+ /** A build-discipline tripwire CROSSED a step (never a repeat at one tier). */
79
+ | {
80
+ readonly kind: 'tripwire';
81
+ readonly tripwire: 'commit-cadence';
82
+ readonly tier: TripwireTier;
83
+ readonly ageMs: number;
84
+ readonly fileCount: number;
85
+ }
86
+ | {
87
+ readonly kind: 'tripwire';
88
+ readonly tripwire: 'unplayed-session';
89
+ readonly tier: TripwireTier;
90
+ readonly servingForMs: number;
91
+ }
92
+ /**
93
+ * A play-mode log session opened or closed.
94
+ *
95
+ * `name` is the run's OPTIONAL slug (`vgai play --name <text>`), `null` for
96
+ * an unnamed run — the same slug that goes in the log filename, so grepping
97
+ * the journal for a run and listing `logs/` for it are the same question.
98
+ */
99
+ | {
100
+ readonly kind: 'play';
101
+ readonly action: 'start' | 'stop';
102
+ readonly name: string | null;
103
+ readonly logFile: string | null;
104
+ };
105
+
106
+ /** A parsed journal line: the event plus when it was appended. */
107
+ export type SessionJournalLine = SessionJournalEvent & { readonly at: string };
108
+
109
+ /** An open journal. `append` never throws — a journal that breaks a save would
110
+ * be worse than a journal that misses a line. */
111
+ export interface SessionJournal {
112
+ /** Absolute path of the file being appended to. */
113
+ readonly path: string;
114
+ append(event: SessionJournalEvent): void;
115
+ }
116
+
117
+ /** `editor-2026-08-09T12-34-56-789Z.jsonl` for a given instant. */
118
+ export function sessionJournalFilename(startedAt: Date): string {
119
+ return `${PREFIX}${startedAt.toISOString().replace(/[:.]/g, '-')}${SUFFIX}`;
120
+ }
121
+
122
+ /**
123
+ * Open (create) this session's journal under `<projectRoot>/logs/`.
124
+ *
125
+ * `null` when the directory cannot be created or the first write fails —
126
+ * an unwritable project must degrade to "no journal", never to a crashed boot.
127
+ */
128
+ export function openSessionJournal(
129
+ projectRoot: string,
130
+ startedAt: Date = new Date(),
131
+ ): SessionJournal | null {
132
+ const logsDir = join(projectRoot, 'logs');
133
+ try {
134
+ mkdirSync(logsDir, { recursive: true });
135
+ } catch {
136
+ return null;
137
+ }
138
+ pruneJournals(logsDir);
139
+ const path = join(logsDir, sessionJournalFilename(startedAt));
140
+ const journal: SessionJournal = {
141
+ path,
142
+ append(event) {
143
+ const line: SessionJournalLine = { at: new Date().toISOString(), ...event };
144
+ try {
145
+ appendFileSync(path, `${JSON.stringify(line)}\n`, 'utf-8');
146
+ } catch {
147
+ /* a journal that breaks the caller is worse than a missing line */
148
+ }
149
+ },
150
+ };
151
+ try {
152
+ appendFileSync(path, '', 'utf-8');
153
+ } catch {
154
+ return null;
155
+ }
156
+ return journal;
157
+ }
158
+
159
+ /** Drop the oldest journals so opening one more stays at `MAX_JOURNAL_FILES`. */
160
+ function pruneJournals(logsDir: string): void {
161
+ try {
162
+ const files = readdirSync(logsDir)
163
+ .filter((f) => f.startsWith(PREFIX) && f.endsWith(SUFFIX))
164
+ .sort();
165
+ for (const f of files.slice(0, Math.max(0, files.length - MAX_JOURNAL_FILES + 1))) {
166
+ try {
167
+ unlinkSync(join(logsDir, f));
168
+ } catch {
169
+ /* already gone */
170
+ }
171
+ }
172
+ } catch {
173
+ /* no logs dir yet */
174
+ }
175
+ }
176
+
177
+ /**
178
+ * The newest journal in a project, or `null`.
179
+ *
180
+ * File-native on purpose (the same shape as the CLI's newest-play-log reader):
181
+ * the journal path is a fact about the PROJECT DIRECTORY, so `vgai status` and
182
+ * the boot pointer can name it without a server round-trip — and can still name
183
+ * it after the session that wrote it is gone.
184
+ */
185
+ export function newestSessionJournal(projectRoot: string): string | null {
186
+ try {
187
+ const files = readdirSync(join(projectRoot, 'logs'))
188
+ .filter((f) => f.startsWith(PREFIX) && f.endsWith(SUFFIX))
189
+ .sort();
190
+ const newest = files.at(-1);
191
+ return newest ? join(projectRoot, 'logs', newest) : null;
192
+ } catch {
193
+ return null;
194
+ }
195
+ }
196
+
197
+ /**
198
+ * The ONE wording for "here is the journal" — printed by the editor server's
199
+ * boot block, by `vgai edit`'s ready/detach lines, and by `vgai status`.
200
+ *
201
+ * One owner of the sentence, for `build-discipline.ts`'s reason: three
202
+ * processes point at this file, and a second phrasing of the same pointer is
203
+ * how a reader ends up believing there are two things.
204
+ */
205
+ export function sessionJournalPointerLine(journalPath: string): string {
206
+ return `Session journal: ${journalPath} — structured JSONL, read it any time`;
207
+ }