@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.
- package/package.json +5 -2
- package/src/account.ts +58 -2
- package/src/generations.ts +6 -0
- package/src/project/build-discipline.ts +721 -0
- package/src/project/run-name.ts +53 -0
- package/src/project/session-journal.ts +495 -0
|
@@ -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
|
+
}
|