@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.
|
|
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.
|
|
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
|
+
}
|