forgeos 0.1.0-alpha.37 → 0.1.0-alpha.39

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.
@@ -47,7 +47,7 @@ const CONTENT_CHECKS: Array<{ file: string; contains: string[] }> = [
47
47
  { file: "docs/agent-workflow.md", contains: ["forge do", "forge status --json", "forge handoff --json"] },
48
48
  { file: "docs/troubleshooting.md", contains: ["FORGE_DELTA_BUSY", "waiting-for-user-trust", "Studio target preview issues"] },
49
49
  { file: "docs/release.md", contains: ["release:verify-public-alpha", "Documentation checklist"] },
50
- { file: "docs/self-host.md", contains: ["forge self-host check"] },
50
+ { file: "docs/self-host.md", contains: ["forge deploy check --production --json"] },
51
51
  ];
52
52
 
53
53
  function read(workspaceRoot: string, relative: string): string | null {
@@ -30,6 +30,41 @@ export interface PgliteDoctorResult {
30
30
  ok: boolean;
31
31
  inspection: PgliteStoreInspection;
32
32
  checks: DoctorCheck[];
33
+ dbGuide: LocalDbModeGuide;
34
+ nextActions: string[];
35
+ exitCode: 0 | 1;
36
+ }
37
+
38
+ export interface LocalDbModeGuide {
39
+ recommendedForCurrentState: "pglite" | "memory" | "stop-active-process" | "repair-pglite";
40
+ memory: {
41
+ command: string;
42
+ useWhen: string[];
43
+ tradeoff: string;
44
+ };
45
+ pglite: {
46
+ command: string;
47
+ useWhen: string[];
48
+ tradeoff: string;
49
+ };
50
+ repair?: {
51
+ command: string;
52
+ safeWhen: string;
53
+ preservesDataByArchiving: boolean;
54
+ };
55
+ }
56
+
57
+ export interface RuntimeDoctorResult {
58
+ ok: boolean;
59
+ checks: DoctorCheck[];
60
+ dev: {
61
+ pidFile: string;
62
+ logFile: string;
63
+ running: boolean;
64
+ pid?: number;
65
+ };
66
+ pglite: PgliteStoreInspection;
67
+ dbGuide: LocalDbModeGuide;
33
68
  nextActions: string[];
34
69
  exitCode: 0 | 1;
35
70
  }
@@ -106,6 +141,50 @@ function frontendDevAuthChecks(
106
141
  }));
107
142
  }
108
143
 
144
+ function buildLocalDbModeGuide(workspaceRoot: string, pglite: PgliteStoreInspection): LocalDbModeGuide {
145
+ const memoryCommand = "forge dev --db memory --json";
146
+ const pgliteCommand = "forge dev --db pglite --json";
147
+ const repairCommand = "forge db repair --local --adapter pglite --json";
148
+ const recommendedForCurrentState: LocalDbModeGuide["recommendedForCurrentState"] =
149
+ pglite.state === "active"
150
+ ? "stop-active-process"
151
+ : pglite.state === "aborted" || pglite.state === "unhealthy"
152
+ ? "repair-pglite"
153
+ : pglite.openable
154
+ ? "pglite"
155
+ : "memory";
156
+ return normalizeForgeCliCommandsInValue(workspaceRoot, {
157
+ recommendedForCurrentState,
158
+ memory: {
159
+ command: memoryCommand,
160
+ useWhen: [
161
+ "you need a clean isolated smoke test",
162
+ "PGlite is active, aborted, or suspected corrupt",
163
+ "you do not need local data to persist after the process exits",
164
+ ],
165
+ tradeoff: "Fast and isolated, but data disappears when the dev process exits.",
166
+ },
167
+ pglite: {
168
+ command: pgliteCommand,
169
+ useWhen: [
170
+ "you want local data to persist between dev runs",
171
+ "you are reproducing real database behavior more closely than memory",
172
+ "doctor pglite reports the store is missing or healthy",
173
+ ],
174
+ tradeoff: "Persists local state, but a stale/corrupt .forge/pglite store can require repair.",
175
+ },
176
+ ...(pglite.state === "aborted" || pglite.state === "unhealthy"
177
+ ? {
178
+ repair: {
179
+ command: repairCommand,
180
+ safeWhen: "PGlite is not owned by a live forge dev process and doctor reports aborted/unhealthy.",
181
+ preservesDataByArchiving: true,
182
+ },
183
+ }
184
+ : {}),
185
+ }) as LocalDbModeGuide;
186
+ }
187
+
109
188
  export async function runDoctorCommand(options: {
110
189
  workspaceRoot: string;
111
190
  }): Promise<DoctorResult> {
@@ -242,15 +321,116 @@ export async function runPgliteDoctorCommand(options: {
242
321
  }
243
322
 
244
323
  const exitOk = checks.every((check) => check.ok || check.severity === "warning");
324
+ const dbGuide = buildLocalDbModeGuide(options.workspaceRoot, inspection);
245
325
  return normalizeForgeCliCommandsInValue(options.workspaceRoot, {
246
326
  ok: exitOk,
247
327
  inspection,
248
328
  checks,
329
+ dbGuide,
249
330
  nextActions: inspection.nextActions,
250
331
  exitCode: exitOk ? 0 : 1,
251
332
  });
252
333
  }
253
334
 
335
+ function isProcessRunning(pid: number): boolean {
336
+ try {
337
+ process.kill(pid, 0);
338
+ return true;
339
+ } catch (error) {
340
+ return error instanceof Error && "code" in error && error.code === "EPERM";
341
+ }
342
+ }
343
+
344
+ function readPid(path: string): number | undefined {
345
+ const text = nodeFileSystem.readText(path);
346
+ if (text === null) return undefined;
347
+ const pid = Number(text.trim());
348
+ return Number.isInteger(pid) && pid > 0 ? pid : undefined;
349
+ }
350
+
351
+ export async function runRuntimeDoctorCommand(options: {
352
+ workspaceRoot: string;
353
+ }): Promise<RuntimeDoctorResult> {
354
+ const checks: DoctorCheck[] = [];
355
+ const generated = await runGenerate({
356
+ workspaceRoot: options.workspaceRoot,
357
+ check: true,
358
+ dryRun: false,
359
+ json: false,
360
+ concurrency: 4,
361
+ });
362
+ checks.push({
363
+ name: "generated",
364
+ ok: generated.exitCode === 0,
365
+ severity: "error",
366
+ message: generated.exitCode === 0 ? "generated artifacts are fresh" : "generated artifacts are stale; run forge generate",
367
+ });
368
+
369
+ const devDir = join(options.workspaceRoot, ".forge", "dev");
370
+ const pidFile = join(devDir, "dev.pid");
371
+ const logFile = join(devDir, "dev.log");
372
+ const pid = readPid(pidFile);
373
+ const running = pid !== undefined && isProcessRunning(pid);
374
+ checks.push({
375
+ name: "dev-lifecycle",
376
+ ok: true,
377
+ severity: "warning",
378
+ message: running
379
+ ? `detached forge dev is running with pid ${pid}`
380
+ : "no detached forge dev server is running",
381
+ });
382
+
383
+ const pglite = await inspectPgliteStore(join(options.workspaceRoot, DEFAULT_PGLITE_DIR));
384
+ const pgliteOk = pglite.state === "missing" || pglite.state === "healthy" || pglite.state === "active";
385
+ checks.push({
386
+ name: "pglite-store",
387
+ ok: pgliteOk,
388
+ severity: pglite.state === "active" ? "warning" : "error",
389
+ message: pglite.error ?? `PGlite store is ${pglite.state}`,
390
+ });
391
+ checks.push({
392
+ name: "pglite-openable",
393
+ ok: pglite.openable || pglite.state === "active",
394
+ severity: pglite.state === "active" ? "warning" : "error",
395
+ message: pglite.openable
396
+ ? "PGlite store is openable"
397
+ : pglite.state === "active"
398
+ ? "PGlite store is owned by a live process"
399
+ : "PGlite store cannot be opened",
400
+ });
401
+
402
+ const ok = checks.every((check) => check.ok || check.severity === "warning");
403
+ const dbGuide = buildLocalDbModeGuide(options.workspaceRoot, pglite);
404
+ const nextActions = ok
405
+ ? ["forge dev --once --json", "forge dev status --json"]
406
+ : [
407
+ ...(generated.exitCode === 0 ? [] : ["forge generate"]),
408
+ ...(pglite.state === "active"
409
+ ? ["stop the running forge dev process that owns .forge/pglite", "forge doctor pglite --json"]
410
+ : []),
411
+ ...(pglite.state === "aborted" || pglite.state === "unhealthy"
412
+ ? ["forge doctor pglite --json", "forge db repair --local --adapter pglite --json", "forge dev --db memory --json"]
413
+ : []),
414
+ ...(!pglite.openable && pglite.state !== "active" && pglite.state !== "aborted" && pglite.state !== "unhealthy"
415
+ ? ["forge doctor pglite --json", "forge dev --db memory --json"]
416
+ : []),
417
+ ];
418
+ return normalizeForgeCliCommandsInValue(options.workspaceRoot, {
419
+ ok,
420
+ checks,
421
+ dev: {
422
+ pidFile,
423
+ logFile,
424
+ running,
425
+ ...(pid !== undefined ? { pid } : {}),
426
+ },
427
+ pglite,
428
+ dbGuide,
429
+ nextActions: [...new Set(nextActions)],
430
+ exitCode: ok ? 0 : 1,
431
+ });
432
+ }
433
+
254
434
  export function formatDoctorJson(result: DoctorResult): string {
255
435
  return `${JSON.stringify(result, null, 2)}\n`;
256
436
  }
@@ -279,6 +459,42 @@ export function formatPgliteDoctorHuman(result: PgliteDoctorResult): string {
279
459
  lines.push("");
280
460
  lines.push(`Store: ${result.inspection.dataDir}`);
281
461
  lines.push(`State: ${result.inspection.state}`);
462
+ lines.push(`Recommended DB mode: ${result.dbGuide.recommendedForCurrentState}`);
463
+ lines.push(`Memory: ${result.dbGuide.memory.command} - ${result.dbGuide.memory.tradeoff}`);
464
+ lines.push(`PGlite: ${result.dbGuide.pglite.command} - ${result.dbGuide.pglite.tradeoff}`);
465
+ if (result.dbGuide.repair) {
466
+ lines.push(`Repair: ${result.dbGuide.repair.command}`);
467
+ }
468
+ if (result.nextActions.length > 0) {
469
+ lines.push("");
470
+ lines.push("Next actions:");
471
+ for (const action of result.nextActions) {
472
+ lines.push(` ${action}`);
473
+ }
474
+ }
475
+ return `${lines.join("\n")}\n`;
476
+ }
477
+
478
+ export function formatRuntimeDoctorJson(result: RuntimeDoctorResult): string {
479
+ return `${JSON.stringify(result, null, 2)}\n`;
480
+ }
481
+
482
+ export function formatRuntimeDoctorHuman(result: RuntimeDoctorResult): string {
483
+ const lines = ["Forge Runtime Doctor", ""];
484
+ for (const check of result.checks) {
485
+ const marker = check.ok ? "OK" : check.severity === "warning" ? "WARN" : "FAIL";
486
+ lines.push(`${marker} ${check.name}${check.message ? ` - ${check.message}` : ""}`);
487
+ }
488
+ lines.push("");
489
+ lines.push(`Dev pid file: ${result.dev.pidFile}`);
490
+ lines.push(`Dev log file: ${result.dev.logFile}`);
491
+ lines.push(`PGlite state: ${result.pglite.state}`);
492
+ lines.push(`Recommended DB mode: ${result.dbGuide.recommendedForCurrentState}`);
493
+ lines.push(`Memory: ${result.dbGuide.memory.command} - ${result.dbGuide.memory.tradeoff}`);
494
+ lines.push(`PGlite: ${result.dbGuide.pglite.command} - ${result.dbGuide.pglite.tradeoff}`);
495
+ if (result.dbGuide.repair) {
496
+ lines.push(`Repair: ${result.dbGuide.repair.command}`);
497
+ }
282
498
  if (result.nextActions.length > 0) {
283
499
  lines.push("");
284
500
  lines.push("Next actions:");
@@ -0,0 +1,328 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { runNewCommand, type NewPackageManager, type NewTemplateName } from "./new.ts";
5
+ import { normalizeForgeCliCommandsInValue } from "../workspace/forge-cli.ts";
6
+
7
+ export type FieldTestSubcommand = "create" | "run" | "report";
8
+
9
+ export interface FieldTestCommandOptions {
10
+ subcommand: FieldTestSubcommand;
11
+ workspaceRoot: string;
12
+ json: boolean;
13
+ name?: string;
14
+ template: NewTemplateName;
15
+ templates?: NewTemplateName[];
16
+ packageManager: NewPackageManager;
17
+ packageManagers?: NewPackageManager[];
18
+ forgeSpec?: string;
19
+ auth?: "none" | "workos";
20
+ dryRun: boolean;
21
+ keep: boolean;
22
+ runtimeProbes: boolean;
23
+ authProbes: boolean;
24
+ timeoutMs: number;
25
+ writeReport?: string;
26
+ }
27
+
28
+ export interface FieldTestCommandResult {
29
+ schemaVersion: "0.1.0";
30
+ ok: boolean;
31
+ kind: "field-test";
32
+ action: FieldTestSubcommand;
33
+ data?: unknown;
34
+ summary?: unknown;
35
+ command?: string[];
36
+ reportPath?: string;
37
+ stdout?: string;
38
+ stderr?: string;
39
+ nextActions: string[];
40
+ exitCode: 0 | 1;
41
+ }
42
+
43
+ function scriptPath(workspaceRoot: string): string {
44
+ return join(workspaceRoot, "scripts", "field-test-forgeos.mjs");
45
+ }
46
+
47
+ function compact(text: string, limit = 12000): string {
48
+ if (text.length <= limit) return text;
49
+ const half = Math.floor(limit / 2);
50
+ return `${text.slice(0, half)}\n...[truncated ${text.length - limit} chars]...\n${text.slice(-half)}`;
51
+ }
52
+
53
+ function defaultReportPath(): string {
54
+ return ".forge/field-test-report.json";
55
+ }
56
+
57
+ function parseJsonOutput(text: string): unknown | null {
58
+ const trimmed = text.trim();
59
+ if (!trimmed) return null;
60
+ try {
61
+ return JSON.parse(trimmed) as unknown;
62
+ } catch {
63
+ const start = trimmed.indexOf("{");
64
+ const end = trimmed.lastIndexOf("}");
65
+ if (start === -1 || end <= start) return null;
66
+ try {
67
+ return JSON.parse(trimmed.slice(start, end + 1)) as unknown;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+ }
73
+
74
+ function summarizeReport(data: unknown) {
75
+ const root = data && typeof data === "object" ? data as Record<string, unknown> : {};
76
+ const results = Array.isArray(root.results) ? root.results as Array<Record<string, unknown>> : [];
77
+ const plannedCases = Array.isArray(root.cases) ? root.cases as Array<Record<string, unknown>> : [];
78
+ const failed = results.filter((result) => result.ok === false && result.skipped !== true);
79
+ const skipped = results.filter((result) => result.skipped === true);
80
+ const runtimeProbeSteps = results.flatMap((result) => {
81
+ const runtime = result.runtime && typeof result.runtime === "object"
82
+ ? result.runtime as Record<string, unknown>
83
+ : {};
84
+ return Array.isArray(runtime.steps) ? runtime.steps as unknown[] : [];
85
+ });
86
+ const ok = root.ok === true;
87
+ const runtimeProbes = root.runtimeProbes === true;
88
+ const authProbes = root.authProbes === true;
89
+ const productionEvidenceMissing = [
90
+ ...(!ok ? ["passing field-test report"] : []),
91
+ ...(!runtimeProbes ? ["runtime probes"] : []),
92
+ ...(!authProbes ? ["auth probes"] : []),
93
+ ...(failed.length > 0 ? ["zero failed cases"] : []),
94
+ ];
95
+ return {
96
+ ok,
97
+ cases: results.length || plannedCases.length,
98
+ executedCases: results.length,
99
+ plannedCases: plannedCases.length,
100
+ passed: results.filter((result) => result.ok === true && result.skipped !== true).length,
101
+ failed: failed.length,
102
+ skipped: skipped.length,
103
+ runtimeProbes,
104
+ authProbes,
105
+ runtimeProbeSteps: runtimeProbeSteps.length,
106
+ productionEvidence: {
107
+ readyForDeployCheck: productionEvidenceMissing.length === 0,
108
+ missing: productionEvidenceMissing,
109
+ deployCheckCommand: "forge deploy check --production --json",
110
+ note: "This proves field-test evidence only; deploy check still validates production auth, database, metadata, lockfile, and tenant posture.",
111
+ },
112
+ failedCases: failed.map((result) => ({
113
+ template: result.template,
114
+ packageManager: result.packageManager,
115
+ reason: result.reason,
116
+ })),
117
+ };
118
+ }
119
+
120
+ function runCommandForOptions(templates: NewTemplateName[], packageManagers: NewPackageManager[]): string {
121
+ return [
122
+ "forge field-test run",
123
+ `--templates ${templates.join(",")}`,
124
+ `--package-managers ${packageManagers.join(",")}`,
125
+ "--runtime-probes",
126
+ "--auth-probes",
127
+ "--json",
128
+ ].join(" ");
129
+ }
130
+
131
+ async function createFieldTestApp(options: FieldTestCommandOptions): Promise<FieldTestCommandResult> {
132
+ if (!options.name) {
133
+ return {
134
+ schemaVersion: "0.1.0",
135
+ ok: false,
136
+ kind: "field-test",
137
+ action: "create",
138
+ data: { error: "forge field-test create requires an app name" },
139
+ nextActions: ["forge field-test create vendor-access --auth workos --template minimal-web --json"],
140
+ exitCode: 1,
141
+ };
142
+ }
143
+ if (options.dryRun) {
144
+ return normalizeForgeCliCommandsInValue(options.workspaceRoot, {
145
+ schemaVersion: "0.1.0",
146
+ ok: true,
147
+ kind: "field-test",
148
+ action: "create",
149
+ data: {
150
+ dryRun: true,
151
+ name: options.name,
152
+ template: options.template,
153
+ packageManager: options.packageManager,
154
+ auth: options.auth,
155
+ command: `forge new ${options.name} --template ${options.template} --package-manager ${options.packageManager} --field-test --install`,
156
+ },
157
+ nextActions: [`forge new ${options.name} --template ${options.template} --package-manager ${options.packageManager} --field-test --install`],
158
+ exitCode: 0,
159
+ });
160
+ }
161
+ const created = await runNewCommand({
162
+ name: options.name,
163
+ template: options.template,
164
+ packageManager: options.packageManager,
165
+ install: true,
166
+ git: true,
167
+ fieldTest: options.auth === "workos",
168
+ forgePackageSpec: options.forgeSpec,
169
+ workspaceRoot: options.workspaceRoot,
170
+ });
171
+ const createdFieldTest = created.fieldTest?.steps ?? [];
172
+ return normalizeForgeCliCommandsInValue(options.workspaceRoot, {
173
+ schemaVersion: "0.1.0",
174
+ ok: created.exitCode === 0,
175
+ kind: "field-test",
176
+ action: "create",
177
+ data: {
178
+ ...created,
179
+ auth: options.auth,
180
+ setup: options.auth === "workos"
181
+ ? {
182
+ applied: createdFieldTest.length > 0 && createdFieldTest.every((step) => step.ok),
183
+ steps: createdFieldTest,
184
+ }
185
+ : {
186
+ applied: false,
187
+ steps: [],
188
+ note: "auth=none creates a plain app; pass --auth workos for WorkOS/AuthKit/auth.md field-test setup.",
189
+ },
190
+ },
191
+ nextActions: [
192
+ `cd ${created.targetDir}`,
193
+ `${options.packageManager} run forge -- agent onboard --target codex --json`,
194
+ `${options.packageManager} run forge -- generate`,
195
+ `${options.packageManager} run forge -- check --json`,
196
+ ...(options.auth === "workos"
197
+ ? [
198
+ `${options.packageManager} run forge -- authmd check --json`,
199
+ `${options.packageManager} run forge -- workos doctor --json`,
200
+ `${options.packageManager} run forge -- workos seed --file workos-seed.yml --dry-run --json`,
201
+ ]
202
+ : []),
203
+ `${options.packageManager} run forge -- dev --once --json`,
204
+ `${options.packageManager} run forge -- verify --smoke --json`,
205
+ `${options.packageManager} run forge -- handoff --json`,
206
+ ],
207
+ exitCode: created.exitCode,
208
+ });
209
+ }
210
+
211
+ function runHarness(options: FieldTestCommandOptions): FieldTestCommandResult {
212
+ const script = scriptPath(options.workspaceRoot);
213
+ if (!existsSync(script)) {
214
+ return {
215
+ schemaVersion: "0.1.0",
216
+ ok: false,
217
+ kind: "field-test",
218
+ action: "run",
219
+ data: { error: "field-test harness script not found", script },
220
+ nextActions: ["run from the ForgeOS framework checkout or use npm run field:test"],
221
+ exitCode: 1,
222
+ };
223
+ }
224
+ const reportPath = options.writeReport ?? (options.dryRun ? undefined : defaultReportPath());
225
+ const templates = options.templates?.length ? options.templates : [options.template];
226
+ const packageManagers = options.packageManagers?.length ? options.packageManagers : [options.packageManager];
227
+ const args = [
228
+ script,
229
+ "--templates",
230
+ templates.join(","),
231
+ "--package-managers",
232
+ packageManagers.join(","),
233
+ "--timeout-ms",
234
+ String(options.timeoutMs),
235
+ ];
236
+ if (options.dryRun) args.push("--dry-run");
237
+ if (options.keep) args.push("--keep");
238
+ if (options.runtimeProbes) args.push("--runtime-probes");
239
+ if (options.authProbes) args.push("--auth-probes");
240
+ if (options.forgeSpec) args.push("--forge-spec", options.forgeSpec);
241
+ if (reportPath) args.push("--write-report", reportPath);
242
+ if (options.json) args.push("--json");
243
+ const result = spawnSync(process.execPath, args, {
244
+ cwd: options.workspaceRoot,
245
+ encoding: "utf8",
246
+ windowsHide: true,
247
+ });
248
+ const reportAbsolute = reportPath ? join(options.workspaceRoot, reportPath) : null;
249
+ const reportData = reportAbsolute && existsSync(reportAbsolute)
250
+ ? JSON.parse(readFileSync(reportAbsolute, "utf8")) as unknown
251
+ : parseJsonOutput(result.stdout ?? "");
252
+ return normalizeForgeCliCommandsInValue(options.workspaceRoot, {
253
+ schemaVersion: "0.1.0",
254
+ ok: result.status === 0,
255
+ kind: "field-test",
256
+ action: "run",
257
+ command: [process.execPath, ...args],
258
+ reportPath,
259
+ data: reportData ?? undefined,
260
+ summary: reportData ? summarizeReport(reportData) : undefined,
261
+ stdout: compact(result.stdout ?? ""),
262
+ stderr: compact(result.stderr ?? ""),
263
+ nextActions: result.status === 0
264
+ ? options.dryRun
265
+ ? [runCommandForOptions(templates, packageManagers)]
266
+ : [`forge field-test report --file ${reportPath ?? defaultReportPath()} --json`, "forge deploy check --production --json"]
267
+ : ["inspect field-test stdout/stderr", "forge doctor pglite --json"],
268
+ exitCode: result.status === 0 ? 0 : 1,
269
+ });
270
+ }
271
+
272
+ function readReport(options: FieldTestCommandOptions): FieldTestCommandResult {
273
+ const candidates = [
274
+ options.writeReport,
275
+ ".forge/field-test-report.json",
276
+ "field-reports/full-alpha.json",
277
+ ].filter(Boolean) as string[];
278
+ const report = candidates.find((candidate) => existsSync(join(options.workspaceRoot, candidate)));
279
+ if (!report) {
280
+ return {
281
+ schemaVersion: "0.1.0",
282
+ ok: false,
283
+ kind: "field-test",
284
+ action: "report",
285
+ data: { searched: candidates },
286
+ nextActions: ["forge field-test run --runtime-probes --auth-probes --write-report .forge/field-test-report.json --json"],
287
+ exitCode: 1,
288
+ };
289
+ }
290
+ const data = JSON.parse(readFileSync(join(options.workspaceRoot, report), "utf8")) as unknown;
291
+ const summary = summarizeReport(data);
292
+ const productionEvidence = (summary as { productionEvidence?: { readyForDeployCheck?: boolean } }).productionEvidence;
293
+ return {
294
+ schemaVersion: "0.1.0",
295
+ ok: (data && typeof data === "object" && "ok" in data) ? (data as { ok?: unknown }).ok === true : true,
296
+ kind: "field-test",
297
+ action: "report",
298
+ reportPath: report,
299
+ summary,
300
+ data,
301
+ nextActions: productionEvidence?.readyForDeployCheck
302
+ ? ["forge deploy check --production --json"]
303
+ : ["forge field-test run --runtime-probes --auth-probes --write-report .forge/field-test-report.json --json"],
304
+ exitCode: (data && typeof data === "object" && "ok" in data && (data as { ok?: unknown }).ok !== true) ? 1 : 0,
305
+ };
306
+ }
307
+
308
+ export async function runFieldTestCommand(options: FieldTestCommandOptions): Promise<FieldTestCommandResult> {
309
+ if (options.subcommand === "create") return createFieldTestApp(options);
310
+ if (options.subcommand === "run") return runHarness(options);
311
+ return readReport(options);
312
+ }
313
+
314
+ export function formatFieldTestJson(result: FieldTestCommandResult): string {
315
+ return `${JSON.stringify(result, null, 2)}\n`;
316
+ }
317
+
318
+ export function formatFieldTestHuman(result: FieldTestCommandResult): string {
319
+ const lines = [
320
+ `field-test ${result.action} ${result.ok ? "ok" : "failed"}`,
321
+ ...(result.reportPath ? [`report: ${result.reportPath}`] : []),
322
+ ...(result.command ? [`command: ${result.command.join(" ")}`] : []),
323
+ ...(result.stdout ? ["", result.stdout] : []),
324
+ ...(result.stderr ? ["", result.stderr] : []),
325
+ ...(result.nextActions.length ? ["", "Next:", ...result.nextActions.map((action) => ` ${action}`)] : []),
326
+ ];
327
+ return `${lines.join("\n")}\n`;
328
+ }
@@ -27,6 +27,7 @@ export interface HandoffCommandResult {
27
27
  generatedChangedFiles: number;
28
28
  frontendReady: boolean;
29
29
  changedFiles: number;
30
+ commitReadyFiles: number;
30
31
  stagedFiles: number;
31
32
  unstagedFiles: number;
32
33
  untrackedFiles: number;
@@ -157,10 +158,14 @@ function buildOpeningBrief(input: {
157
158
  dev: DevConsoleCycle;
158
159
  git: HandoffCommandResult["git"];
159
160
  recentRuns: HandoffCommandResult["recentRuns"];
161
+ commitReady?: HandoffCommandResult["commitReady"];
160
162
  }): string {
161
163
  const agent = input.dev.summary.agentContext;
162
164
  const changedByType = summarizeChangeTypes(input.git.changeSummary.changed);
163
165
  const changedFiles = Math.max(agent.changedFiles, input.git.changed.count);
166
+ const commitReadyNote = input.commitReady && input.commitReady.count !== changedFiles
167
+ ? `; ${input.commitReady.count} commit-ready file(s) after excluding generated/operational artifacts`
168
+ : "";
164
169
  const tests = input.recentRuns.test
165
170
  ? input.recentRuns.test.ok
166
171
  ? "last test run passed"
@@ -171,7 +176,7 @@ function buildOpeningBrief(input: {
171
176
  : "no blocking issues";
172
177
  return [
173
178
  `ForgeOS handoff: ${input.dev.ok ? "dev diagnostics are clean" : "dev diagnostics need attention"}.`,
174
- `${changedFiles} changed file(s)${changedByType ? `: ${changedByType}` : ""}; ${input.git.staged.count} staged, ${input.git.untracked.count} untracked.`,
179
+ `${changedFiles} changed file(s)${changedByType ? `: ${changedByType}` : ""}${commitReadyNote}; ${input.git.staged.count} staged, ${input.git.untracked.count} untracked.`,
175
180
  `${tests}; ${blockers}.`,
176
181
  `Next command: ${input.dev.summary.primaryAction?.command ?? input.dev.nextActions[0]?.command ?? "forge dev"}.`,
177
182
  ].join(" ");
@@ -211,9 +216,7 @@ export async function runHandoffCommand(options: HandoffCommandOptions): Promise
211
216
  includeImpact: true,
212
217
  });
213
218
  const git = buildWorkspaceGitSummary(workspaceRoot);
214
- const commitReady = options.commitReady
215
- ? runChangedCommand(workspaceRoot, { commitReady: true }).data.commitReady as { count: number; files: string[] } | undefined
216
- : undefined;
219
+ const commitReady = runChangedCommand(workspaceRoot, { commitReady: true }).data.commitReady as { count: number; files: string[] } | undefined;
217
220
  const recentRuns = summarizeRecentRuns(workspaceRoot);
218
221
  const agent = dev.summary.agentContext;
219
222
  const risks = [
@@ -224,6 +227,9 @@ export async function runHandoffCommand(options: HandoffCommandOptions): Promise
224
227
  ? ["git status is unavailable; using filesystem inventory as untracked-file analysis"]
225
228
  : []),
226
229
  ...(git.untracked.count > 0 && git.source !== "forge-baseline" ? [`${git.untracked.count} untracked file(s) are not in git history`] : []),
230
+ ...(commitReady && git.changed.count > commitReady.count
231
+ ? [`${git.changed.count - commitReady.count} generated or operational file(s) are excluded from the commit-ready view`]
232
+ : []),
227
233
  ...(recentRuns.test && !recentRuns.test.ok ? ["last test run failed"] : []),
228
234
  ...(recentRuns.ui && !recentRuns.ui.ok ? ["last UI run failed"] : []),
229
235
  ];
@@ -231,6 +237,7 @@ export async function runHandoffCommand(options: HandoffCommandOptions): Promise
231
237
  ...agent.recommendedCommands,
232
238
  ...forgeCliCommandsForWorkspace(workspaceRoot, [
233
239
  ...(git.changed.count > 0 ? ["forge review run --changed --json"] : []),
240
+ ...(git.changed.count > 0 ? ["forge changed --commit-ready --json"] : []),
234
241
  "forge handoff --json",
235
242
  ]),
236
243
  ];
@@ -252,6 +259,7 @@ export async function runHandoffCommand(options: HandoffCommandOptions): Promise
252
259
  generatedChangedFiles: agent.generatedChangedFiles,
253
260
  frontendReady: agent.frontendReady,
254
261
  changedFiles,
262
+ commitReadyFiles: commitReady?.count ?? changedFiles,
255
263
  stagedFiles: git.staged.count,
256
264
  unstagedFiles: git.unstaged.count,
257
265
  untrackedFiles: git.untracked.count,
@@ -273,7 +281,7 @@ export async function runHandoffCommand(options: HandoffCommandOptions): Promise
273
281
  git,
274
282
  recentRuns,
275
283
  nextAgent: {
276
- openingBrief: buildOpeningBrief({ dev, git, recentRuns }),
284
+ openingBrief: buildOpeningBrief({ dev, git, recentRuns, commitReady }),
277
285
  recommendedReadFiles: agent.recommendedReadFiles,
278
286
  recommendedCommands: [...new Set(nextActions)].slice(0, 10),
279
287
  risks,