@vgai/sdk 0.5.4 → 0.5.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.
@@ -0,0 +1,721 @@
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, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
48
+ import { dirname, 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
+ * THE TWO TIERS SAY DIFFERENT KINDS OF THING, deliberately. Notice DESCRIBES
151
+ * (`cadenceLine`): at ten minutes a reader is mid-slice and the useful signal
152
+ * is the clock. Loud PRESCRIBES: it opens with the imperative and the literal
153
+ * two commands, because the measured failure is not ignorance of the rule —
154
+ * three consecutive probes quoted "one commit per slice" back at the reader
155
+ * and then batch-committed anyway. A banner that restates a rule the reader
156
+ * already agrees with adds nothing; the one that names the next keystroke is
157
+ * the one that can change the outcome.
158
+ */
159
+ export function commitCadenceBanner(work: UncommittedWork | null): string | null {
160
+ const tier = commitCadenceTier(work);
161
+ if (!work || tier === 'silent') return null;
162
+ if (tier === 'notice') return cadenceLine(work);
163
+ const age = formatElapsed(work.ageMs);
164
+ return [
165
+ '================================================================',
166
+ ` COMMIT NOW — UNCOMMITTED WORK FOR ${age.toUpperCase()}`,
167
+ '================================================================',
168
+ ' Commit NOW, one commit per slice (AGENTS.md):',
169
+ ' git add <your files> && git commit -m "<mechanic>"',
170
+ '',
171
+ ` Uncommitted work is ${age} old across ${work.fileCount} file(s).`,
172
+ ' A build that lands as a single end-of-run commit cannot be',
173
+ ' reviewed, bisected, or partially recovered when a later step goes',
174
+ ' wrong — and a build that reaches this banner is on exactly that',
175
+ ' path. Commit the slice that already works, then keep going.',
176
+ '================================================================',
177
+ ].join('\n');
178
+ }
179
+
180
+ /**
181
+ * The same tripwire as ONE line, at every step including the loud one.
182
+ *
183
+ * For channels whose output is parsed rather than read — `vgai eval` prints a
184
+ * game's own JSON, and a fifteen-line block dropped beside it corrupts more
185
+ * than it warns. Same sentence as the notice step of the banner (`cadenceLine`
186
+ * is the single source); the only thing dropped is the block.
187
+ */
188
+ export function commitCadenceNotice(work: UncommittedWork | null): string | null {
189
+ if (!work || commitCadenceTier(work) === 'silent') return null;
190
+ return cadenceLine(work);
191
+ }
192
+
193
+ /**
194
+ * The dirty paths in a `git status --porcelain -z` payload — pure, because the
195
+ * record shape is the one part of this that is easy to get quietly wrong.
196
+ *
197
+ * `-z` rather than plain porcelain because porcelain QUOTES paths containing
198
+ * special characters, and a quoted path stats as nothing. Records are
199
+ * `XY <path>`; a rename adds a second, prefix-less chunk holding the SOURCE
200
+ * path, which the shape test skips — the destination is the file on disk.
201
+ * Paths are relative to the repository root, not to cwd.
202
+ */
203
+ export function dirtyPathsFromPorcelain(porcelain: string): string[] {
204
+ const paths: string[] = [];
205
+ for (const chunk of porcelain.split('\0')) {
206
+ const match = /^(..) (.+)$/.exec(chunk);
207
+ if (match?.[2]) paths.push(match[2]);
208
+ }
209
+ return paths;
210
+ }
211
+
212
+ /** Runs git in `cwd`. `null` for ANY failure — git missing, not a repository,
213
+ * unborn HEAD, non-zero exit — because each of those means "this project
214
+ * cannot be asked the question", not "this project is behind". */
215
+ function git(cwd: string, args: string[]): string | null {
216
+ const result = spawnSync('git', args, { cwd, encoding: 'utf-8' });
217
+ if (result.error || result.status !== 0) return null;
218
+ return result.stdout ?? '';
219
+ }
220
+
221
+ /**
222
+ * Read the current uncommitted batch for `projectRoot`.
223
+ *
224
+ * `null` — say nothing — when there is no project, no git work tree, or a
225
+ * clean tree. Two `git` invocations plus one `stat` per dirty path (capped),
226
+ * made on a caller's own event (a status request, a finished playtest run, a
227
+ * save the dev server already validated) — never on a timer.
228
+ */
229
+ export function readUncommittedWork(
230
+ projectRoot: string | null,
231
+ now: number = Date.now(),
232
+ ): UncommittedWork | null {
233
+ if (!projectRoot) return null;
234
+ const topLevel = git(projectRoot, ['rev-parse', '--show-toplevel'])?.trim();
235
+ if (!topLevel) return null;
236
+
237
+ // The `.` pathspec is what scopes the question to this project when it sits
238
+ // inside a larger repo.
239
+ const porcelain = git(projectRoot, ['status', '--porcelain', '-z', '--', '.']);
240
+ if (porcelain === null) return null;
241
+ const paths = dirtyPathsFromPorcelain(porcelain);
242
+ if (paths.length === 0) return null;
243
+
244
+ let oldest: number | null = null;
245
+ for (const relative of paths.slice(0, MAX_DIRTY_PATHS_DATED)) {
246
+ try {
247
+ const mtime = statSync(join(topLevel, relative)).mtimeMs;
248
+ if (oldest === null || mtime < oldest) oldest = mtime;
249
+ } catch {
250
+ /* a deleted path dates nothing */
251
+ }
252
+ }
253
+
254
+ const committedAt = git(projectRoot, ['log', '-1', '--format=%ct'])?.trim();
255
+ const lastCommitAtMs =
256
+ committedAt && /^\d+$/.test(committedAt) ? Number.parseInt(committedAt, 10) * 1000 : null;
257
+
258
+ const ageMs = uncommittedWorkAge(now, lastCommitAtMs, oldest);
259
+ if (ageMs === null) return null;
260
+ return { ageMs, fileCount: paths.length };
261
+ }
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // Live evidence
265
+ // ---------------------------------------------------------------------------
266
+
267
+ /** Directories never walked when dating a project's source: build output,
268
+ * dependencies, and the evidence directories themselves (a run's artifacts
269
+ * must never read as a source change). */
270
+ const SKIP_DIRS = new Set([
271
+ 'node_modules',
272
+ '.git',
273
+ '.vgai',
274
+ '.agents',
275
+ '.claude',
276
+ '.github',
277
+ 'dist',
278
+ 'dist-server',
279
+ 'build',
280
+ 'logs',
281
+ 'coverage',
282
+ '.turbo',
283
+ '.vite',
284
+ 'test-results',
285
+ 'playwright-report',
286
+ ]);
287
+
288
+ /** Extensions that count as project source for dating purposes. */
289
+ const SOURCE_EXT = ['.ts', '.tsx', '.js', '.jsx', '.json', '.css', '.html'];
290
+
291
+ /** Bound on the walk. A project big enough to exceed it is one where the
292
+ * answer is dominated by the first few thousand files anyway, and a status
293
+ * poll must stay cheap whatever it is pointed at. */
294
+ const MAX_FILES_WALKED = 4000;
295
+
296
+ /**
297
+ * The newest mtime (epoch ms) among the project's own source files, or `null`
298
+ * when there are none to date.
299
+ *
300
+ * `src/` plus the manifest and package.json — the files whose change means
301
+ * "the build is different now". Deliberately NOT the whole tree: a screenshot
302
+ * landing in `.vgai/`, a log line, or a `node_modules` touch is not a source
303
+ * change, and treating it as one would make the banner cry wolf on its own
304
+ * evidence.
305
+ */
306
+ export function newestSourceMtime(projectRoot: string): number | null {
307
+ let newest: number | null = null;
308
+ let walked = 0;
309
+
310
+ const consider = (file: string): void => {
311
+ try {
312
+ const at = statSync(file).mtimeMs;
313
+ if (newest === null || at > newest) newest = at;
314
+ } catch {
315
+ /* a file that vanished mid-walk dates nothing */
316
+ }
317
+ };
318
+
319
+ const isDirectory = (file: string): boolean | null => {
320
+ try {
321
+ return statSync(file).isDirectory();
322
+ } catch {
323
+ return null; // vanished mid-walk
324
+ }
325
+ };
326
+
327
+ const walk = (dir: string): void => {
328
+ let entries: string[];
329
+ try {
330
+ entries = readdirSync(dir);
331
+ } catch {
332
+ return;
333
+ }
334
+ for (const entry of entries) {
335
+ if (walked >= MAX_FILES_WALKED) return;
336
+ if (SKIP_DIRS.has(entry)) continue;
337
+ const full = join(dir, entry);
338
+ const isDir = isDirectory(full);
339
+ if (isDir === null) continue;
340
+ if (isDir) walk(full);
341
+ else if (SOURCE_EXT.some((ext) => entry.endsWith(ext))) {
342
+ walked += 1;
343
+ consider(full);
344
+ }
345
+ }
346
+ };
347
+
348
+ walk(join(projectRoot, 'src'));
349
+ for (const file of ['vgai.project.json', 'package.json']) {
350
+ const full = join(projectRoot, file);
351
+ if (existsSync(full)) consider(full);
352
+ }
353
+ return newest;
354
+ }
355
+
356
+ /**
357
+ * The newest mtime (epoch ms) among this project's LIVE-EVIDENCE artifacts, or
358
+ * `null` when the game has never run.
359
+ *
360
+ * The two artifacts are the same pair the idiom checker's "has this ever been
361
+ * played" rule reads, for the same reason: both are written by the tools
362
+ * themselves while a real browser runs the real game, so neither can be
363
+ * produced by intending to play.
364
+ * - `logs/play-*.jsonl` — one per Play session, opened by the editor server.
365
+ * - `.vgai/last-run/**` — what a bot run leaves behind (screenshots, and the
366
+ * `playtest.json` verdict `npm run playtest` files).
367
+ */
368
+ export function newestEvidenceMtime(projectRoot: string): number | null {
369
+ let newest: number | null = null;
370
+ const consider = (file: string): void => {
371
+ try {
372
+ const at = statSync(file).mtimeMs;
373
+ if (newest === null || at > newest) newest = at;
374
+ } catch {
375
+ /* unreadable: not evidence */
376
+ }
377
+ };
378
+
379
+ const logsDir = join(projectRoot, 'logs');
380
+ try {
381
+ for (const entry of readdirSync(logsDir)) {
382
+ if (entry.startsWith('play-') && entry.endsWith('.jsonl')) consider(join(logsDir, entry));
383
+ }
384
+ } catch {
385
+ /* no logs dir */
386
+ }
387
+
388
+ const runDir = join(projectRoot, '.vgai', 'last-run');
389
+ try {
390
+ for (const entry of readdirSync(runDir)) consider(join(runDir, entry));
391
+ } catch {
392
+ /* no run dir */
393
+ }
394
+ return newest;
395
+ }
396
+
397
+ /** The later of two optional instants — how a caller combines the two walks
398
+ * above into one "when did anything about this project last change" number. */
399
+ export function latestOf(a: number | null, b: number | null): number | null {
400
+ if (a === null) return b;
401
+ if (b === null) return a;
402
+ return Math.max(a, b);
403
+ }
404
+
405
+ /**
406
+ * The loud banner for SOURCE THAT HAS NEVER RUN — pure so it can be driven
407
+ * directly by a test.
408
+ *
409
+ * `null` in the two honest silences:
410
+ * - a project with no datable source (nothing to be stale);
411
+ * - source no newer than the newest evidence (the running build IS this code).
412
+ *
413
+ * NOT silent when there is no evidence at all: a project that has never run is
414
+ * the strongest form of the thing this reports, not an exemption from it.
415
+ */
416
+ export function staleEvidenceBanner(
417
+ newestSource: number | null,
418
+ newestEvidence: number | null,
419
+ ): string | null {
420
+ if (newestSource === null) return null;
421
+ if (newestEvidence !== null && newestEvidence >= newestSource) return null;
422
+ const gap =
423
+ newestEvidence === null
424
+ ? ' no live evidence exists at all — no logs/play-*.jsonl, no .vgai/last-run artifacts.'
425
+ : ` newest source: ${new Date(newestSource).toISOString()}\n` +
426
+ ` newest live evidence: ${new Date(newestEvidence).toISOString()}`;
427
+ return [
428
+ '================================================================',
429
+ ' SOURCE CHANGED SINCE LAST LIVE EVIDENCE',
430
+ '================================================================',
431
+ gap,
432
+ '',
433
+ ' The shipped build has never run. Typecheck, tests and validators all',
434
+ ' answer questions about the source; whether it WORKS is a question only',
435
+ ' running it can answer — and the pixel-only failures (blank canvas,',
436
+ ' invisible mesh, camera inside the geometry) are invisible to every one',
437
+ ' of them.',
438
+ '',
439
+ ' Play or playtest before claiming it works: `vgai play`, then look, or',
440
+ ' `npm run playtest` for the bot proof.',
441
+ '================================================================',
442
+ ].join('\n');
443
+ }
444
+
445
+ /**
446
+ * The escalation steps for "this session has never played", in ms.
447
+ *
448
+ * NOTICE at 5 minutes: the opening minutes of a session are legitimately spent
449
+ * reading the brief and the scaffold, so a line before that would fire on
450
+ * every single session and be tuned out by the second one.
451
+ *
452
+ * LOUD at 15 minutes: the measured blind stretch was 11 minutes, so the loud
453
+ * step lands just past it rather than at the end of a build — by 15 minutes a
454
+ * session has authored real gameplay it has never once watched run.
455
+ */
456
+ const UNPLAYED_NOTICE_MS = 5 * 60_000;
457
+ const UNPLAYED_LOUD_MS = 15 * 60_000;
458
+
459
+ /**
460
+ * Which step the "never played" clock has reached — the ONE place the unplayed
461
+ * thresholds are compared.
462
+ *
463
+ * `silent` in the honest silences: no session serving this project (no clock to
464
+ * run), or evidence at least as new as the session start (this session HAS
465
+ * played, and how long ago is `staleEvidenceBanner`'s question, not this one).
466
+ */
467
+ export function unplayedSessionTier(
468
+ sessionStartedAtMs: number | null,
469
+ newestEvidence: number | null,
470
+ now: number,
471
+ ): TripwireTier {
472
+ if (sessionStartedAtMs === null) return 'silent';
473
+ if (newestEvidence !== null && newestEvidence >= sessionStartedAtMs) return 'silent';
474
+ const servingForMs = now - sessionStartedAtMs;
475
+ if (servingForMs < UNPLAYED_NOTICE_MS) return 'silent';
476
+ return servingForMs < UNPLAYED_LOUD_MS ? 'notice' : 'loud';
477
+ }
478
+
479
+ /**
480
+ * The escalating line/banner for a session that has served this project
481
+ * without ever producing live-play evidence — pure, driven directly by a test.
482
+ *
483
+ * The evidence signal is the SAME pair `newestEvidenceMtime` walks (a Play
484
+ * session's `logs/play-*.jsonl`, a bot run's `.vgai/last-run/**`), so this
485
+ * banner and the staleness banner can never disagree about what counts as
486
+ * having run. Nothing new is instrumented on the game side: both artifacts are
487
+ * already written by the tools themselves while a real browser runs the real
488
+ * game, which is exactly why neither can be produced by intending to play.
489
+ */
490
+ export function unplayedSessionBanner(
491
+ sessionStartedAtMs: number | null,
492
+ newestEvidence: number | null,
493
+ now: number,
494
+ ): string | null {
495
+ const tier = unplayedSessionTier(sessionStartedAtMs, newestEvidence, now);
496
+ if (tier === 'silent' || sessionStartedAtMs === null) return null;
497
+ const elapsed = formatElapsed(now - sessionStartedAtMs);
498
+ if (tier === 'notice') {
499
+ return (
500
+ `unplayed for ${elapsed} — this editor session has never run the game; ` +
501
+ 'start it now (`vgai play`) rather than at the end'
502
+ );
503
+ }
504
+ return [
505
+ '================================================================',
506
+ ` ${elapsed.toUpperCase()} SERVING THIS PROJECT, NEVER ONCE PLAYED`,
507
+ '================================================================',
508
+ ' No Play session and no bot run has produced any live evidence since',
509
+ ' this editor session started.',
510
+ '',
511
+ ' Every gate that has passed so far answered a question about the',
512
+ ' SOURCE. The failures that only a running game shows — blank canvas,',
513
+ ' invisible mesh, camera inside the geometry, input that reaches',
514
+ ' nothing — are invisible to all of them, and the longer the first play',
515
+ ' is deferred the more work is stacked on top of an unverified base.',
516
+ '',
517
+ ' Play it now: `vgai play`, then look — or `npm run playtest` for the',
518
+ ' bot proof.',
519
+ '================================================================',
520
+ ].join('\n');
521
+ }
522
+
523
+ // ---------------------------------------------------------------------------
524
+ // The event-driven channel's gate
525
+ // ---------------------------------------------------------------------------
526
+
527
+ /**
528
+ * What an event-driven channel remembers between events.
529
+ *
530
+ * The editor dev server prints these banners on the save/validation event it
531
+ * ALREADY handles — no timer, no poller — and a save burst is dozens of those
532
+ * events in a second. Two things therefore have to be bounded: how often the
533
+ * (git + stat) reads run at all, and how often the same warning is repeated.
534
+ */
535
+ export interface TripwireGate {
536
+ /** When the reads last ran, or `null` if they never have. */
537
+ readonly evaluatedAtMs: number | null;
538
+ /** The highest tier announced since the tripwire last went quiet. */
539
+ readonly announced: TripwireTier;
540
+ }
541
+
542
+ /** A gate that has never evaluated and never announced — the initial state, and
543
+ * what a project switch resets to. */
544
+ export const IDLE_TRIPWIRE_GATE: TripwireGate = { evaluatedAtMs: null, announced: 'silent' };
545
+
546
+ /**
547
+ * Floor on how often the reads run, whatever the event rate.
548
+ *
549
+ * A minute is far finer than any threshold here (the earliest is 5 minutes),
550
+ * so it costs the banner no timeliness at all, while a 50-file save burst pays
551
+ * for at most one `git status` instead of fifty.
552
+ */
553
+ export const TRIPWIRE_MIN_EVAL_INTERVAL_MS = 60_000;
554
+
555
+ /** May this event pay for the reads, or has one already run recently enough? */
556
+ export function shouldEvaluateTripwires(gate: TripwireGate, now: number): boolean {
557
+ return gate.evaluatedAtMs === null || now - gate.evaluatedAtMs >= TRIPWIRE_MIN_EVAL_INTERVAL_MS;
558
+ }
559
+
560
+ /**
561
+ * Fold a freshly measured tier into the gate: print only on a CROSSING.
562
+ *
563
+ * `announce` is true exactly when the tier is higher than the highest one
564
+ * already announced, so a burst of saves at the same tier prints once, the
565
+ * escalation to `loud` prints again, and dropping back (a commit landed, a play
566
+ * happened) re-arms the gate for the next crossing. Debounce as a rank
567
+ * comparison rather than a wall-clock window: it is what "at most once per
568
+ * threshold crossing" literally means, and it needs no clock to be correct.
569
+ */
570
+ export function advanceTripwireGate(
571
+ gate: TripwireGate,
572
+ tier: TripwireTier,
573
+ now: number,
574
+ ): { readonly announce: boolean; readonly gate: TripwireGate } {
575
+ return {
576
+ announce: TIER_RANK[tier] > TIER_RANK[gate.announced],
577
+ gate: { evaluatedAtMs: now, announced: tier },
578
+ };
579
+ }
580
+
581
+ // ---------------------------------------------------------------------------
582
+ // The gate, across process restarts
583
+ // ---------------------------------------------------------------------------
584
+
585
+ /**
586
+ * WHY THE GATE IS ON DISK.
587
+ *
588
+ * Measured failure (the "foundry" blind probe, 45 minutes): the commit-cadence
589
+ * tripwire announced at NOTICE mid-run — `{"tier":"notice","ageMs":679238,
590
+ * "fileCount":23}` is in that session's journal — and the LOUD step never
591
+ * announced once across three editor-server restarts, while the agent went on
592
+ * to batch-commit all 23 files at the end. The gate above is correct and the
593
+ * process holding it is not: an editor server restarts (a crash, a config
594
+ * change, `vgai restart`) far more often than a dirty batch resolves, and each
595
+ * restart re-armed at `announced: 'silent'`, so a batch that had already been
596
+ * noticed simply got noticed AGAIN at the same tier — never escalated. The
597
+ * loudest step of the escalation was unreachable by construction for exactly
598
+ * the builds it exists to catch.
599
+ *
600
+ * WHAT IS KEYED, AND WHY IT IS NOT A TIMESTAMP. The persisted record carries a
601
+ * KEY alongside the announced tier, and a key that differs from the one being
602
+ * asked about reads as a fresh gate. The key is what "the tripwire went quiet"
603
+ * means for that tripwire — the same thing the in-memory gate re-arms on:
604
+ * - `commit-cadence` — the last commit (`commitCadenceGateKey`). A commit
605
+ * lands, the key changes, the next batch is heard from zero.
606
+ * - `unplayed-session` — the newest live evidence
607
+ * (`unplayedSessionGateKey`). A play happens, the key changes, the clock
608
+ * starts over.
609
+ * `evaluatedAtMs` is deliberately NOT persisted: it is the read cap, a fact
610
+ * about one process's event rate, and carrying it across a restart would blind
611
+ * the first minute of the new session for no benefit.
612
+ */
613
+
614
+ /** The tripwires with a persisted gate — the same names the session journal
615
+ * uses, so a journal line and a gate entry can never disagree about which
616
+ * tripwire is meant. */
617
+ export type TripwireName = 'commit-cadence' | 'unplayed-session';
618
+
619
+ /** One tripwire's persisted state. */
620
+ interface PersistedGate {
621
+ readonly key: string;
622
+ readonly announced: TripwireTier;
623
+ }
624
+
625
+ /**
626
+ * Where the gate lives, project-relative.
627
+ *
628
+ * Under `.vgai/` beside the other machine-local caches (`check-idioms.json`),
629
+ * and inside a directory `newestSourceMtime` already skips — so the file the
630
+ * tripwire writes can never read as a source change and make the tripwire cry
631
+ * wolf on its own output. It is gitignored in the scaffold for the same
632
+ * reason it must never count: a gate write that dirtied the tree would add a
633
+ * file to the very `fileCount` it is reporting.
634
+ */
635
+ export const TRIPWIRE_GATE_PATH = join('.vgai', 'tripwire-gate.json');
636
+
637
+ function gateFilePath(projectRoot: string): string {
638
+ return join(projectRoot, TRIPWIRE_GATE_PATH);
639
+ }
640
+
641
+ /** The whole file, or an empty record. Corrupt, missing, unreadable and
642
+ * not-an-object are ONE case: a fresh gate, never a throw. A discipline
643
+ * banner that crashed a save would be worse than one that repeated itself. */
644
+ function readGateFile(projectRoot: string): Record<string, PersistedGate> {
645
+ try {
646
+ const parsed: unknown = JSON.parse(readFileSync(gateFilePath(projectRoot), 'utf-8'));
647
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
648
+ return parsed as Record<string, PersistedGate>;
649
+ } catch {
650
+ return {};
651
+ }
652
+ }
653
+
654
+ /** Best-effort write. Same rule as the read: never throws. */
655
+ function writeGateFile(projectRoot: string, file: Record<string, PersistedGate>): void {
656
+ const path = gateFilePath(projectRoot);
657
+ try {
658
+ mkdirSync(dirname(path), { recursive: true });
659
+ writeFileSync(path, `${JSON.stringify(file, null, 2)}\n`, 'utf-8');
660
+ } catch {
661
+ /* an unwritable project degrades to the in-memory gate */
662
+ }
663
+ }
664
+
665
+ /**
666
+ * The highest tier already announced for `name` under `key` — `silent` when
667
+ * nothing is recorded, when the file is unreadable, or when the recorded key
668
+ * is a different one (which is what "the tripwire went quiet" looks like on
669
+ * disk).
670
+ */
671
+ export function readAnnouncedTier(
672
+ projectRoot: string,
673
+ name: TripwireName,
674
+ key: string,
675
+ ): TripwireTier {
676
+ const entry = readGateFile(projectRoot)[name];
677
+ if (!entry || entry.key !== key) return 'silent';
678
+ return entry.announced in TIER_RANK ? entry.announced : 'silent';
679
+ }
680
+
681
+ /** The commit-cadence key: the commit the current batch sits on top of.
682
+ * A constant outside a work tree — the tripwire is silent there anyway. */
683
+ export function commitCadenceGateKey(projectRoot: string | null): string {
684
+ if (!projectRoot) return 'no-project';
685
+ return git(projectRoot, ['rev-parse', 'HEAD'])?.trim() || 'unborn';
686
+ }
687
+
688
+ /** The unplayed-session key: the newest live evidence this project has, which
689
+ * is precisely what makes that tripwire go quiet. */
690
+ export function unplayedSessionGateKey(newestEvidence: number | null): string {
691
+ return newestEvidence === null ? 'never-played' : String(newestEvidence);
692
+ }
693
+
694
+ /**
695
+ * {@link advanceTripwireGate}, with the announced tier read from and written
696
+ * back to the project — so a crossing announces once per crossing rather than
697
+ * once per crossing PER PROCESS.
698
+ *
699
+ * The in-memory gate is still the caller's: it carries `evaluatedAtMs` (the
700
+ * read cap) and its own `announced` is folded in as a floor, so a failed write
701
+ * degrades to the old behavior rather than to a repeating banner. Persistence
702
+ * rides this call and nothing else — there is no poller, no watcher, and no
703
+ * second place that touches the file.
704
+ */
705
+ export function advancePersistedTripwireGate(
706
+ projectRoot: string,
707
+ name: TripwireName,
708
+ key: string,
709
+ gate: TripwireGate,
710
+ tier: TripwireTier,
711
+ now: number,
712
+ ): { readonly announce: boolean; readonly gate: TripwireGate } {
713
+ const onDisk = readAnnouncedTier(projectRoot, name, key);
714
+ const announced = TIER_RANK[onDisk] > TIER_RANK[gate.announced] ? onDisk : gate.announced;
715
+ const step = advanceTripwireGate({ evaluatedAtMs: gate.evaluatedAtMs, announced }, tier, now);
716
+ writeGateFile(projectRoot, {
717
+ ...readGateFile(projectRoot),
718
+ [name]: { key, announced: step.gate.announced },
719
+ });
720
+ return step;
721
+ }