@vgai/sdk 0.5.5 → 0.5.7
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 +3 -2
- package/src/account.ts +58 -2
- package/src/generations.ts +6 -0
- package/src/project/build-discipline.ts +161 -13
- package/src/project/run-name.ts +53 -0
- package/src/project/session-journal.ts +289 -1
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.7",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
@@ -24,12 +24,13 @@
|
|
|
24
24
|
"./project-tool-catalog": "./src/project-tool-catalog.ts",
|
|
25
25
|
"./project-inspection-node": "./src/project/inspection-node.ts",
|
|
26
26
|
"./registry": "./src/registry.ts",
|
|
27
|
+
"./run-name": "./src/project/run-name.ts",
|
|
27
28
|
"./session-journal": "./src/project/session-journal.ts",
|
|
28
29
|
"./tools": "./src/tools.ts"
|
|
29
30
|
},
|
|
30
31
|
"dependencies": {
|
|
31
32
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
32
|
-
"@vgai/engine": "0.5.
|
|
33
|
+
"@vgai/engine": "0.5.7",
|
|
33
34
|
"playwright": "^1.58.2",
|
|
34
35
|
"zod": "^4.3.6"
|
|
35
36
|
}
|
package/src/account.ts
CHANGED
|
@@ -32,6 +32,13 @@ export const AccountSpendPolicySchema = z.object({
|
|
|
32
32
|
})
|
|
33
33
|
.optional(),
|
|
34
34
|
});
|
|
35
|
+
export const AccountAlertSchema = z.object({
|
|
36
|
+
id: z.string().min(1),
|
|
37
|
+
kind: z.enum(['threshold', 'auto_reload_succeeded', 'auto_reload_failed']),
|
|
38
|
+
message: z.string().min(1),
|
|
39
|
+
createdAt: z.string().datetime(),
|
|
40
|
+
readAt: z.string().datetime().optional(),
|
|
41
|
+
});
|
|
35
42
|
export const AccountUserSchema = z.object({
|
|
36
43
|
id: z.string().min(1),
|
|
37
44
|
email: z.string().email(),
|
|
@@ -57,13 +64,46 @@ export const AccountOrganizationSchema = z.object({
|
|
|
57
64
|
export const AccountUsageEntrySchema = z.object({
|
|
58
65
|
id: z.string().min(1),
|
|
59
66
|
occurredAt: z.string().datetime(),
|
|
60
|
-
kind: z.
|
|
67
|
+
kind: z.enum(['inference', 'generation', 'workspace', 'storage', 'relay', 'deployment']),
|
|
61
68
|
provider: z.string().min(1),
|
|
62
69
|
operation: z.string().optional(),
|
|
63
70
|
credits: z.number().nonnegative(),
|
|
64
71
|
state: z.enum(['reserved', 'settled', 'released']),
|
|
65
72
|
externalId: z.string().optional(),
|
|
66
73
|
});
|
|
74
|
+
export const AccountPlanIdSchema = z.enum(['free', 'creator', 'max', 'ultra']);
|
|
75
|
+
export const AccountCatalogSchema = z.object({
|
|
76
|
+
plans: z.object({
|
|
77
|
+
free: z.object({
|
|
78
|
+
name: z.string().min(1),
|
|
79
|
+
includedCredits: z.number().int().nonnegative(),
|
|
80
|
+
monthlyPriceUsd: z.number().nonnegative().optional(),
|
|
81
|
+
}),
|
|
82
|
+
creator: z.object({
|
|
83
|
+
name: z.string().min(1),
|
|
84
|
+
includedCredits: z.number().int().nonnegative(),
|
|
85
|
+
monthlyPriceUsd: z.number().nonnegative().optional(),
|
|
86
|
+
}),
|
|
87
|
+
max: z.object({
|
|
88
|
+
name: z.string().min(1),
|
|
89
|
+
includedCredits: z.number().int().nonnegative(),
|
|
90
|
+
monthlyPriceUsd: z.number().nonnegative().optional(),
|
|
91
|
+
}),
|
|
92
|
+
ultra: z.object({
|
|
93
|
+
name: z.string().min(1),
|
|
94
|
+
includedCredits: z.number().int().nonnegative(),
|
|
95
|
+
monthlyPriceUsd: z.number().nonnegative().optional(),
|
|
96
|
+
}),
|
|
97
|
+
}),
|
|
98
|
+
creditPacks: z.record(
|
|
99
|
+
z.string().min(1),
|
|
100
|
+
z.object({
|
|
101
|
+
name: z.string().min(1),
|
|
102
|
+
credits: z.number().int().positive(),
|
|
103
|
+
expiresInDays: z.number().int().positive().optional(),
|
|
104
|
+
}),
|
|
105
|
+
),
|
|
106
|
+
});
|
|
67
107
|
export const AccountSnapshotSchema = z.discriminatedUnion('authenticated', [
|
|
68
108
|
z.object({ authenticated: z.literal(false), backend: AccountBackendSchema }),
|
|
69
109
|
z.object({
|
|
@@ -74,6 +114,7 @@ export const AccountSnapshotSchema = z.discriminatedUnion('authenticated', [
|
|
|
74
114
|
plan: AccountPlanSchema,
|
|
75
115
|
credits: AccountCreditsSchema,
|
|
76
116
|
spendPolicy: AccountSpendPolicySchema,
|
|
117
|
+
alerts: z.array(AccountAlertSchema).max(100).optional(),
|
|
77
118
|
entitlements: z.object({
|
|
78
119
|
generation: z.boolean(),
|
|
79
120
|
dailyJobs: z.number().int().positive().optional(),
|
|
@@ -83,7 +124,12 @@ export const AccountSnapshotSchema = z.discriminatedUnion('authenticated', [
|
|
|
83
124
|
/** Product-facing routes. Provider tools may translate BYOK to their native
|
|
84
125
|
* transport name (`direct`) internally. */
|
|
85
126
|
export const GenerationExecutionRouteSchema = z.enum(['mock', 'managed', 'byok']);
|
|
86
|
-
export const ProviderCredentialIdSchema = z.enum(['fal', 'tripo', 'worldlabs']);
|
|
127
|
+
export const ProviderCredentialIdSchema = z.enum(['fal', 'tripo', 'worldlabs', 'openrouter']);
|
|
128
|
+
export const CodingInferenceSettingsSchema = z.object({
|
|
129
|
+
enabled: z.boolean(),
|
|
130
|
+
provider: z.literal('openrouter'),
|
|
131
|
+
model: z.string().trim().min(1).max(256),
|
|
132
|
+
});
|
|
87
133
|
export const ProviderCredentialSourceSchema = z.enum(['environment', 'system', 'session', 'none']);
|
|
88
134
|
export const ProviderCredentialStatusSchema = z.object({
|
|
89
135
|
provider: ProviderCredentialIdSchema,
|
|
@@ -106,21 +152,29 @@ export type AccountBackend = z.infer<typeof AccountBackendSchema>;
|
|
|
106
152
|
export type AccountPlan = z.infer<typeof AccountPlanSchema>;
|
|
107
153
|
export type AccountCredits = z.infer<typeof AccountCreditsSchema>;
|
|
108
154
|
export type AccountSpendPolicy = z.infer<typeof AccountSpendPolicySchema>;
|
|
155
|
+
export type AccountAlert = z.infer<typeof AccountAlertSchema>;
|
|
109
156
|
export type AccountUser = z.infer<typeof AccountUserSchema>;
|
|
110
157
|
export type AccountOrganization = z.infer<typeof AccountOrganizationSchema>;
|
|
111
158
|
export type AccountOrganizationDomain = z.infer<typeof AccountOrganizationDomainSchema>;
|
|
112
159
|
export type AccountUsageEntry = z.infer<typeof AccountUsageEntrySchema>;
|
|
160
|
+
export type AccountPlanId = z.infer<typeof AccountPlanIdSchema>;
|
|
161
|
+
export type AccountCatalog = z.infer<typeof AccountCatalogSchema>;
|
|
113
162
|
export type AccountSnapshot = z.infer<typeof AccountSnapshotSchema>;
|
|
114
163
|
export type GenerationExecutionRoute = z.infer<typeof GenerationExecutionRouteSchema>;
|
|
164
|
+
export type CodingInferenceSettings = z.infer<typeof CodingInferenceSettingsSchema>;
|
|
115
165
|
export type ProviderCredentialId = z.infer<typeof ProviderCredentialIdSchema>;
|
|
116
166
|
export type ProviderCredentialSource = z.infer<typeof ProviderCredentialSourceSchema>;
|
|
117
167
|
export type ProviderCredentialStatus = z.infer<typeof ProviderCredentialStatusSchema>;
|
|
118
168
|
export type ProviderCredentialTestResult = z.infer<typeof ProviderCredentialTestResultSchema>;
|
|
119
169
|
export type EditorAccountSnapshot = AccountSnapshot & {
|
|
170
|
+
/** Which identity/payment surface backs this editor session. The in-process
|
|
171
|
+
* mock value exists only for isolated unit tests. */
|
|
172
|
+
accountEnvironment: 'test-mock' | 'development-twin' | 'production';
|
|
120
173
|
routes: { mock: true; managed: boolean; byok: boolean; byokProviders: string[] };
|
|
121
174
|
preferredRoute: GenerationExecutionRoute;
|
|
122
175
|
/** Editor-only metadata. Credential values never cross the server boundary. */
|
|
123
176
|
providerCredentials: ProviderCredentialStatus[];
|
|
177
|
+
codingInference: CodingInferenceSettings;
|
|
124
178
|
};
|
|
125
179
|
export interface GenerationAccountProjection {
|
|
126
180
|
routes: EditorAccountSnapshot['routes'];
|
|
@@ -142,6 +196,7 @@ export function generationAccountProjection(
|
|
|
142
196
|
export const EditorAccountSnapshotSchema: z.ZodType<EditorAccountSnapshot> = z.intersection(
|
|
143
197
|
AccountSnapshotSchema,
|
|
144
198
|
z.object({
|
|
199
|
+
accountEnvironment: z.enum(['test-mock', 'development-twin', 'production']),
|
|
145
200
|
routes: z.object({
|
|
146
201
|
mock: z.literal(true),
|
|
147
202
|
managed: z.boolean(),
|
|
@@ -150,5 +205,6 @@ export const EditorAccountSnapshotSchema: z.ZodType<EditorAccountSnapshot> = z.i
|
|
|
150
205
|
}),
|
|
151
206
|
preferredRoute: GenerationExecutionRouteSchema,
|
|
152
207
|
providerCredentials: z.array(ProviderCredentialStatusSchema),
|
|
208
|
+
codingInference: CodingInferenceSettingsSchema,
|
|
153
209
|
}),
|
|
154
210
|
);
|
package/src/generations.ts
CHANGED
|
@@ -62,6 +62,9 @@ export const GenerationJobSchema = z.object({
|
|
|
62
62
|
cancel: GenerationOperationReferenceSchema.optional(),
|
|
63
63
|
accept: GenerationOperationReferenceSchema.optional(),
|
|
64
64
|
acceptedAt: z.string().datetime().optional(),
|
|
65
|
+
/** Last time a human/agent opened the native result. A later provider
|
|
66
|
+
* update makes the terminal result unread again without changing status. */
|
|
67
|
+
readAt: z.string().datetime().optional(),
|
|
65
68
|
provenanceOperationId: z.string().optional(),
|
|
66
69
|
outputPaths: z.array(z.string()).optional(),
|
|
67
70
|
});
|
|
@@ -103,6 +106,9 @@ export interface GenerationJobUpdate {
|
|
|
103
106
|
message?: string;
|
|
104
107
|
billing?: GenerationBilling;
|
|
105
108
|
cancel?: { tool: string; input: unknown };
|
|
109
|
+
/** `null` withdraws an acceptance action after polling proves that a
|
|
110
|
+
* successful operation produced no downloadable project output. */
|
|
111
|
+
accept?: { tool: string; input: unknown } | null;
|
|
106
112
|
accepted?: {
|
|
107
113
|
provenanceOperationId: string;
|
|
108
114
|
outputPaths: string[];
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
*/
|
|
45
45
|
|
|
46
46
|
import { spawnSync } from 'node:child_process';
|
|
47
|
-
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
48
|
-
import { join } from 'node:path';
|
|
47
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
48
|
+
import { dirname, join } from 'node:path';
|
|
49
49
|
|
|
50
50
|
// ---------------------------------------------------------------------------
|
|
51
51
|
// Shared vocabulary
|
|
@@ -146,27 +146,33 @@ function cadenceLine(work: UncommittedWork): string {
|
|
|
146
146
|
* The escalating stderr line/banner for uncommitted work — pure, driven
|
|
147
147
|
* directly by a test. `null` below NOTICE and whenever there is no batch at
|
|
148
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.
|
|
149
158
|
*/
|
|
150
159
|
export function commitCadenceBanner(work: UncommittedWork | null): string | null {
|
|
151
160
|
const tier = commitCadenceTier(work);
|
|
152
161
|
if (!work || tier === 'silent') return null;
|
|
153
162
|
if (tier === 'notice') return cadenceLine(work);
|
|
154
163
|
const age = formatElapsed(work.ageMs);
|
|
155
|
-
const files = `${work.fileCount} file(s)`;
|
|
156
164
|
return [
|
|
157
165
|
'================================================================',
|
|
158
|
-
` UNCOMMITTED WORK FOR ${age.toUpperCase()}`,
|
|
166
|
+
` COMMIT NOW — UNCOMMITTED WORK FOR ${age.toUpperCase()}`,
|
|
159
167
|
'================================================================',
|
|
160
|
-
|
|
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.',
|
|
168
|
+
' Commit NOW, one commit per slice (AGENTS.md):',
|
|
169
|
+
' git add <your files> && git commit -m "<mechanic>"',
|
|
166
170
|
'',
|
|
167
|
-
|
|
168
|
-
'
|
|
169
|
-
'
|
|
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.',
|
|
170
176
|
'================================================================',
|
|
171
177
|
].join('\n');
|
|
172
178
|
}
|
|
@@ -571,3 +577,145 @@ export function advanceTripwireGate(
|
|
|
571
577
|
gate: { evaluatedAtMs: now, announced: tier },
|
|
572
578
|
};
|
|
573
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
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a run is CALLED — one owner of the bound, and of the fallback a run
|
|
3
|
+
* gets when nobody named it.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS. `vgai play --name <text>` and `npm run playtest --
|
|
6
|
+
* '{"name":"…"}'` both exist so a run is findable by what it was testing, and
|
|
7
|
+
* the measured answer to an optional field is that it stays empty: every play
|
|
8
|
+
* event in the foundry probe's 45-minute session journal reads `"name":null`.
|
|
9
|
+
* A label nobody supplies indexes nothing, so the run that DOES know what it
|
|
10
|
+
* was testing supplies it — a route-targeted playtest is named after its
|
|
11
|
+
* routes, and a full-suite run is named `playtest`. Nothing is taught and no
|
|
12
|
+
* habit is required; the default carries the information.
|
|
13
|
+
*
|
|
14
|
+
* WHAT STAYS UNNAMED, deliberately: interactive `vgai play` with no `--name`.
|
|
15
|
+
* A person pressing play is not testing a named thing, and inventing a label
|
|
16
|
+
* for it would put noise in exactly the directory this makes greppable.
|
|
17
|
+
*
|
|
18
|
+
* Pure — no filesystem, no clock. `editor-server.ts`'s `playRunSlug` is the
|
|
19
|
+
* OTHER half (slugging a name into a filename segment) and reads the same
|
|
20
|
+
* bound from here, so the two can never disagree about how long a run name
|
|
21
|
+
* may be.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Bound on a run's name — long enough to stay recognisable in a directory
|
|
25
|
+
* listing, short enough that the timestamp beside it is still readable. */
|
|
26
|
+
export const MAX_RUN_NAME = 40;
|
|
27
|
+
|
|
28
|
+
/** The name a full-suite run gets: it targets no route in particular, and
|
|
29
|
+
* "which run was that" is still a question worth being able to answer. */
|
|
30
|
+
export const FULL_SUITE_RUN_NAME = 'playtest';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The name for one playtest run.
|
|
34
|
+
*
|
|
35
|
+
* An explicit name always wins — it is the caller saying what this run was,
|
|
36
|
+
* and no derivation is better informed than that. Otherwise the routes name
|
|
37
|
+
* it, joined with `+` (a separator that survives being read back as a list,
|
|
38
|
+
* unlike the `-` that route names themselves use), bounded at
|
|
39
|
+
* {@link MAX_RUN_NAME}; and a run that named no route is
|
|
40
|
+
* {@link FULL_SUITE_RUN_NAME}.
|
|
41
|
+
*
|
|
42
|
+
* `null` is impossible by construction — every playtest run gets a name — but
|
|
43
|
+
* an explicit blank/whitespace name is treated as no name at all, the same
|
|
44
|
+
* case a missing one is.
|
|
45
|
+
*/
|
|
46
|
+
export function derivePlaytestRunName(
|
|
47
|
+
explicit: string | null | undefined,
|
|
48
|
+
routes: readonly string[],
|
|
49
|
+
): string {
|
|
50
|
+
if (typeof explicit === 'string' && explicit.trim() !== '') return explicit.trim();
|
|
51
|
+
if (routes.length === 0) return FULL_SUITE_RUN_NAME;
|
|
52
|
+
return routes.join('+').slice(0, MAX_RUN_NAME).replace(/\+$/, '');
|
|
53
|
+
}
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
* grows unbounded, which is what an append-only record means.
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
|
-
import { appendFileSync, mkdirSync, readdirSync, unlinkSync } from 'node:fs';
|
|
35
|
+
import { appendFileSync, mkdirSync, readdirSync, readFileSync, unlinkSync } from 'node:fs';
|
|
36
36
|
import { join } from 'node:path';
|
|
37
37
|
import type { TripwireTier } from './build-discipline';
|
|
38
38
|
|
|
@@ -101,6 +101,148 @@ export type SessionJournalEvent =
|
|
|
101
101
|
readonly action: 'start' | 'stop';
|
|
102
102
|
readonly name: string | null;
|
|
103
103
|
readonly logFile: string | null;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* THE TRANSPORT ARM. Everything below is one line per transport or
|
|
107
|
+
* presence fact, and it exists because the journal was BLIND to all of
|
|
108
|
+
* them: on 2026-08-09 a session refused five `vgai play` commands against
|
|
109
|
+
* a healthy connected tab and the whole file it left behind was
|
|
110
|
+
* `session-started` plus validation lines. A relay that can refuse has to
|
|
111
|
+
* be able to say who it refused, to which tab, and on what evidence.
|
|
112
|
+
*
|
|
113
|
+
* Ids are truncated to 8 characters (`clientId8`, `tabId8`, `requestId8`)
|
|
114
|
+
* — enough to correlate lines within one session, short enough that a
|
|
115
|
+
* human can scan a column of them. No payload bodies, ever: a state
|
|
116
|
+
* snapshot or a command argument list would turn an append-only record
|
|
117
|
+
* into a memory dump.
|
|
118
|
+
*/
|
|
119
|
+
/** A control connection opened (either transport). */
|
|
120
|
+
| {
|
|
121
|
+
readonly kind: 'client-connected';
|
|
122
|
+
readonly clientId8: string;
|
|
123
|
+
readonly transport: 'ws' | 'sse';
|
|
124
|
+
readonly participant: string | null;
|
|
125
|
+
}
|
|
126
|
+
/** A control connection closed, and how many pending commands it settled. */
|
|
127
|
+
| {
|
|
128
|
+
readonly kind: 'client-disconnected';
|
|
129
|
+
readonly clientId8: string;
|
|
130
|
+
readonly transport: 'ws' | 'sse';
|
|
131
|
+
/** WebSocket close code, or null for SSE (which has none). */
|
|
132
|
+
readonly code: number | null;
|
|
133
|
+
readonly reason: string | null;
|
|
134
|
+
readonly commandsSettled: number;
|
|
135
|
+
}
|
|
136
|
+
/** The server granted a socket the duplex control capability. */
|
|
137
|
+
| { readonly kind: 'duplex-granted'; readonly clientId8: string }
|
|
138
|
+
/** A command left the relay for one tab's command channel. */
|
|
139
|
+
| {
|
|
140
|
+
readonly kind: 'command-relayed';
|
|
141
|
+
readonly command: string;
|
|
142
|
+
readonly requestId8: string;
|
|
143
|
+
readonly tabId8: string | null;
|
|
144
|
+
readonly clientId8: string | null;
|
|
145
|
+
}
|
|
146
|
+
/** The tab said its command listener PICKED THE COMMAND UP. */
|
|
147
|
+
| { readonly kind: 'command-receipt'; readonly requestId8: string }
|
|
148
|
+
/** The command settled — by the tab's own answer or by the relay refusing. */
|
|
149
|
+
| {
|
|
150
|
+
readonly kind: 'command-result';
|
|
151
|
+
readonly requestId8: string;
|
|
152
|
+
readonly ok: boolean;
|
|
153
|
+
/** Present only when `ok` is false; the refusal/failure text. */
|
|
154
|
+
readonly error?: string;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The relay HELD a command instead of refusing it: the target tab is
|
|
158
|
+
* present by heartbeat but its command channel is not carrying right now
|
|
159
|
+
* (mid-reload, or a channel that has not answered an echo).
|
|
160
|
+
*/
|
|
161
|
+
| {
|
|
162
|
+
readonly kind: 'command-held';
|
|
163
|
+
readonly requestId8: string;
|
|
164
|
+
readonly tabId8: string;
|
|
165
|
+
readonly reason: 'no-channel' | 'unacknowledged';
|
|
166
|
+
}
|
|
167
|
+
/** The main-thread echo probe over a duplex socket. */
|
|
168
|
+
| {
|
|
169
|
+
readonly kind: 'echo-probe';
|
|
170
|
+
readonly clientId8: string;
|
|
171
|
+
readonly answered: boolean;
|
|
172
|
+
readonly waitedMs: number;
|
|
173
|
+
}
|
|
174
|
+
/** The blessed tab changed (or was chosen for the first time). */
|
|
175
|
+
| {
|
|
176
|
+
readonly kind: 'tab-blessed';
|
|
177
|
+
readonly tabId8: string;
|
|
178
|
+
readonly previousTabId8: string | null;
|
|
179
|
+
readonly reason: 'sticky' | 'oldest' | 'claimed';
|
|
180
|
+
}
|
|
181
|
+
/** An extra tab was told to yield. */
|
|
182
|
+
| { readonly kind: 'tab-yielded'; readonly tabId8: string }
|
|
183
|
+
/**
|
|
184
|
+
* PRESENCE, IN TAB VOCABULARY. These five say what the tab table saw, and
|
|
185
|
+
* they deliberately do NOT use socket words: a socket closing is not a tab
|
|
186
|
+
* leaving, and that conflation is what produced "editor tab lost —
|
|
187
|
+
* reopening" against a tab that had never gone anywhere.
|
|
188
|
+
*/
|
|
189
|
+
/** A tabId beat for the first time. */
|
|
190
|
+
| {
|
|
191
|
+
readonly kind: 'tab-appeared';
|
|
192
|
+
readonly tabId8: string;
|
|
193
|
+
readonly visibility: 'visible' | 'hidden';
|
|
194
|
+
}
|
|
195
|
+
/** Beats stopped arriving for longer than the notice threshold. */
|
|
196
|
+
| { readonly kind: 'tab-heartbeat-gap'; readonly tabId8: string; readonly sinceMs: number }
|
|
197
|
+
/** A gap closed — the same tab resumed beating. `gapMs` is its full length. */
|
|
198
|
+
| { readonly kind: 'tab-gap-closed'; readonly tabId8: string; readonly gapMs: number }
|
|
199
|
+
/** Same tabId, new epoch: the page reloaded. The tab never left. */
|
|
200
|
+
| { readonly kind: 'tab-reloaded'; readonly tabId8: string; readonly epochCount: number }
|
|
201
|
+
/** Absent past its grace: this tab is gone. */
|
|
202
|
+
| { readonly kind: 'tab-departed'; readonly tabId8: string; readonly absentMs: number }
|
|
203
|
+
/**
|
|
204
|
+
* WHAT THE TAB WAS HOLDING WHEN IT DIED WITHOUT A GOODBYE.
|
|
205
|
+
*
|
|
206
|
+
* Emitted beside `client-disconnected` on an ABNORMAL close (1006 — the
|
|
207
|
+
* socket ended with no close frame, which is what a killed renderer process
|
|
208
|
+
* leaves behind; an ordinary tab close sends one). Measured 2026-08-10: a
|
|
209
|
+
* game tab's renderer was killed repeatedly at Chrome's undocumented
|
|
210
|
+
* per-process ceiling and the whole session record said `1006` and nothing
|
|
211
|
+
* else — the recovery worked perfectly and the CAUSE was unrecorded.
|
|
212
|
+
*
|
|
213
|
+
* `census` is the tab's last resource profile and `censusAgeMs` how stale it
|
|
214
|
+
* was; both null for a tab that never reported one (an older page, a
|
|
215
|
+
* tunnelled tab). Numbers only — this stays a journal line, not a dump.
|
|
216
|
+
*/
|
|
217
|
+
| {
|
|
218
|
+
readonly kind: 'tab-death-profile';
|
|
219
|
+
readonly tabId8: string;
|
|
220
|
+
/** The abnormal close code that triggered the line. */
|
|
221
|
+
readonly code: number;
|
|
222
|
+
readonly censusAgeMs: number | null;
|
|
223
|
+
readonly census: {
|
|
224
|
+
readonly heapUsedMB: number | null;
|
|
225
|
+
readonly heapLimitMB: number | null;
|
|
226
|
+
readonly canvases: number;
|
|
227
|
+
readonly canvasMB: number;
|
|
228
|
+
readonly textures?: number;
|
|
229
|
+
readonly geometries?: number;
|
|
230
|
+
readonly programs?: number;
|
|
231
|
+
} | null;
|
|
232
|
+
}
|
|
233
|
+
/** Two epochs beating under one tabId — "Duplicate Tab" copied sessionStorage. */
|
|
234
|
+
| { readonly kind: 'tab-duplicated'; readonly tabId8: string }
|
|
235
|
+
/**
|
|
236
|
+
* The tab is present (its worker is beating) but its PAGE has never opened
|
|
237
|
+
* a command channel this page-load. A heartbeat proves the tab exists, not
|
|
238
|
+
* that the document works — a main thread that died after the inline
|
|
239
|
+
* bootstrap beats forever and can run nothing. Such a tab is passed over
|
|
240
|
+
* for blessing and named in the refusal.
|
|
241
|
+
*/
|
|
242
|
+
| {
|
|
243
|
+
readonly kind: 'tab-unresponsive';
|
|
244
|
+
readonly tabId8: string;
|
|
245
|
+
readonly noChannelForMs: number;
|
|
104
246
|
};
|
|
105
247
|
|
|
106
248
|
/** A parsed journal line: the event plus when it was appended. */
|
|
@@ -194,6 +336,152 @@ export function newestSessionJournal(projectRoot: string): string | null {
|
|
|
194
336
|
}
|
|
195
337
|
}
|
|
196
338
|
|
|
339
|
+
/**
|
|
340
|
+
* The newest journal's last `limit` lines of the requested kinds, oldest first.
|
|
341
|
+
*
|
|
342
|
+
* WHY A READER EXISTS AT ALL. The journal was written as a file an agent
|
|
343
|
+
* "can read at any time", and the measured answer to that invitation was: it
|
|
344
|
+
* doesn't. The foundry probe's journal held the notice-tier tripwire crossing
|
|
345
|
+
* that predicted its own end-of-run batch commit, and nothing that agent ran
|
|
346
|
+
* ever rendered a line of it. So the record grows a RENDERER on the command
|
|
347
|
+
* agents already poll (`vgai status`) — same principle as the tripwires' own
|
|
348
|
+
* origin: the mechanism was right and the delivery assumption was false.
|
|
349
|
+
*
|
|
350
|
+
* Every failure is the same case — no project, no journal, unreadable file, a
|
|
351
|
+
* line that will not parse — and it is the empty list, never a throw. A status
|
|
352
|
+
* command that crashed on a malformed log line would be a worse outcome than
|
|
353
|
+
* one that says nothing about it.
|
|
354
|
+
*
|
|
355
|
+
* The whole file is read, which is what bounds this: a journal is one editor
|
|
356
|
+
* session's surface-worthy transitions (the happy-path silence rule keeps
|
|
357
|
+
* clean saves out), the pruner caps how many exist, and the alternative — a
|
|
358
|
+
* reverse streaming reader over a file measured in kilobytes — is machinery
|
|
359
|
+
* with nothing to buy.
|
|
360
|
+
*/
|
|
361
|
+
export function readRecentJournalEvents(
|
|
362
|
+
projectRoot: string,
|
|
363
|
+
kinds: readonly SessionJournalEvent['kind'][],
|
|
364
|
+
limit: number,
|
|
365
|
+
): SessionJournalLine[] {
|
|
366
|
+
const path = newestSessionJournal(projectRoot);
|
|
367
|
+
if (path === null || limit <= 0) return [];
|
|
368
|
+
let raw: string;
|
|
369
|
+
try {
|
|
370
|
+
raw = readFileSync(path, 'utf-8');
|
|
371
|
+
} catch {
|
|
372
|
+
return [];
|
|
373
|
+
}
|
|
374
|
+
const wanted = new Set<string>(kinds);
|
|
375
|
+
const matched: SessionJournalLine[] = [];
|
|
376
|
+
for (const line of raw.split('\n')) {
|
|
377
|
+
if (line === '') continue;
|
|
378
|
+
let parsed: SessionJournalLine;
|
|
379
|
+
try {
|
|
380
|
+
parsed = JSON.parse(line) as SessionJournalLine;
|
|
381
|
+
} catch {
|
|
382
|
+
continue; // a torn final line is not a reason to report nothing
|
|
383
|
+
}
|
|
384
|
+
if (parsed && typeof parsed === 'object' && wanted.has(parsed.kind)) matched.push(parsed);
|
|
385
|
+
}
|
|
386
|
+
return matched.slice(-limit);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* One journal line as a scannable terminal line: `HH:MM:SS`, the kind, and
|
|
391
|
+
* the few facts that line is about.
|
|
392
|
+
*
|
|
393
|
+
* ONE OWNER of this rendering, for `sessionJournalPointerLine`'s reason — the
|
|
394
|
+
* events already have exactly one wording as data, and a second prose form per
|
|
395
|
+
* caller is how a reader ends up believing there are two records.
|
|
396
|
+
*/
|
|
397
|
+
export function formatJournalLine(line: SessionJournalLine): string {
|
|
398
|
+
const at = line.at.slice(11, 19);
|
|
399
|
+
switch (line.kind) {
|
|
400
|
+
case 'tripwire':
|
|
401
|
+
return line.tripwire === 'commit-cadence'
|
|
402
|
+
? `journal: ${at} tripwire commit-cadence ${line.tier} (${line.fileCount} files, ${Math.round(line.ageMs / 60_000)}m)`
|
|
403
|
+
: `journal: ${at} tripwire unplayed-session ${line.tier} (${Math.round(line.servingForMs / 60_000)}m)`;
|
|
404
|
+
case 'validation':
|
|
405
|
+
return `journal: ${at} validation ${line.ok ? 'ok' : 'FAILED'} ${line.path}`;
|
|
406
|
+
case 'play':
|
|
407
|
+
return `journal: ${at} play ${line.action}${line.name === null ? '' : ` [${line.name}]`}`;
|
|
408
|
+
case 'session-started':
|
|
409
|
+
return `journal: ${at} session-started pid ${line.pid}`;
|
|
410
|
+
case 'project-opened':
|
|
411
|
+
return `journal: ${at} project-opened ${line.project}`;
|
|
412
|
+
case 'session-shutdown':
|
|
413
|
+
return `journal: ${at} session-shutdown`;
|
|
414
|
+
// The TRANSPORT arm. One short line each, and every one of them names the
|
|
415
|
+
// TAB or the request it is about — a column of ids is how these lines get
|
|
416
|
+
// correlated, and a paragraph per line is how a reader stops reading.
|
|
417
|
+
case 'client-connected':
|
|
418
|
+
return `journal: ${at} client-connected ${line.clientId8} (${line.transport})`;
|
|
419
|
+
case 'client-disconnected':
|
|
420
|
+
return `journal: ${at} client-disconnected ${line.clientId8} code ${line.code ?? '-'} settled ${line.commandsSettled}`;
|
|
421
|
+
case 'duplex-granted':
|
|
422
|
+
return `journal: ${at} duplex-granted ${line.clientId8}`;
|
|
423
|
+
case 'command-relayed':
|
|
424
|
+
return `journal: ${at} command-relayed ${line.command} ${line.requestId8} -> tab ${line.tabId8 ?? 'none'}`;
|
|
425
|
+
case 'command-receipt':
|
|
426
|
+
return `journal: ${at} command-receipt ${line.requestId8}`;
|
|
427
|
+
case 'command-result':
|
|
428
|
+
return `journal: ${at} command-result ${line.requestId8} ${line.ok ? 'ok' : `FAILED ${line.error ?? ''}`}`;
|
|
429
|
+
case 'command-held':
|
|
430
|
+
return `journal: ${at} command-held ${line.requestId8} tab ${line.tabId8} (${line.reason})`;
|
|
431
|
+
case 'echo-probe':
|
|
432
|
+
return `journal: ${at} echo-probe ${line.clientId8} ${line.answered ? 'answered' : 'UNANSWERED'} in ${line.waitedMs}ms`;
|
|
433
|
+
case 'tab-blessed':
|
|
434
|
+
return `journal: ${at} tab-blessed ${line.tabId8} (${line.reason})`;
|
|
435
|
+
case 'tab-yielded':
|
|
436
|
+
return `journal: ${at} tab-yielded ${line.tabId8}`;
|
|
437
|
+
case 'tab-appeared':
|
|
438
|
+
return `journal: ${at} tab-appeared ${line.tabId8} (${line.visibility})`;
|
|
439
|
+
case 'tab-heartbeat-gap':
|
|
440
|
+
return `journal: ${at} tab-heartbeat-gap ${line.tabId8} ${Math.round(line.sinceMs / 100) / 10}s`;
|
|
441
|
+
case 'tab-gap-closed':
|
|
442
|
+
return `journal: ${at} tab-gap-closed ${line.tabId8} after ${Math.round(line.gapMs / 100) / 10}s`;
|
|
443
|
+
case 'tab-reloaded':
|
|
444
|
+
return `journal: ${at} tab-reloaded ${line.tabId8} (page load ${line.epochCount})`;
|
|
445
|
+
case 'tab-departed':
|
|
446
|
+
return `journal: ${at} tab-departed ${line.tabId8} absent ${Math.round(line.absentMs / 100) / 10}s`;
|
|
447
|
+
case 'tab-duplicated':
|
|
448
|
+
return `journal: ${at} tab-duplicated ${line.tabId8}`;
|
|
449
|
+
case 'tab-unresponsive':
|
|
450
|
+
return `journal: ${at} tab-unresponsive ${line.tabId8} no channel for ${Math.round(line.noChannelForMs / 1000)}s`;
|
|
451
|
+
case 'tab-death-profile':
|
|
452
|
+
return `journal: ${at} tab-death-profile ${line.tabId8} code ${line.code} — ${tabDeathProfileBody(line)}`;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** The census half of a `tab-death-profile` line, split out so the switch above
|
|
457
|
+
* stays one expression per case. */
|
|
458
|
+
function tabDeathProfileBody(line: SessionJournalEvent & { kind: 'tab-death-profile' }): string {
|
|
459
|
+
const census = formatTabCensus(line.census);
|
|
460
|
+
if (line.censusAgeMs === null) return census;
|
|
461
|
+
return `${census} (sampled ${Math.round(line.censusAgeMs / 100) / 10}s before)`;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** The resource profile a `tab-death-profile` line and a `vgai status` tab row
|
|
465
|
+
* both quote. ONE wording, for `sessionJournalPointerLine`'s reason: two
|
|
466
|
+
* phrasings of the same numbers is how a reader ends up believing there are
|
|
467
|
+
* two measurements. Renderer counts appear only when the page could read them
|
|
468
|
+
* (no mounted render-debug adapter = the words are absent, never a zero). */
|
|
469
|
+
export function formatTabCensus(
|
|
470
|
+
census: (SessionJournalEvent & { kind: 'tab-death-profile' })['census'],
|
|
471
|
+
): string {
|
|
472
|
+
if (census === null) return 'no resource census';
|
|
473
|
+
const parts = [
|
|
474
|
+
census.heapUsedMB === null
|
|
475
|
+
? 'heap n/a'
|
|
476
|
+
: `heap ${census.heapUsedMB}MB${census.heapLimitMB === null ? '' : `/${census.heapLimitMB}MB`}`,
|
|
477
|
+
`canvas ${census.canvasMB}MB in ${census.canvases}`,
|
|
478
|
+
];
|
|
479
|
+
if (census.textures !== undefined) parts.push(`tex ${census.textures}`);
|
|
480
|
+
if (census.geometries !== undefined) parts.push(`geo ${census.geometries}`);
|
|
481
|
+
if (census.programs !== undefined) parts.push(`prog ${census.programs}`);
|
|
482
|
+
return parts.join(', ');
|
|
483
|
+
}
|
|
484
|
+
|
|
197
485
|
/**
|
|
198
486
|
* The ONE wording for "here is the journal" — printed by the editor server's
|
|
199
487
|
* boot block, by `vgai edit`'s ready/detach lines, and by `vgai status`.
|