taphound 0.2.0-dev.11 → 0.2.0-dev.12

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.
Files changed (44) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/assets/skills/taphound-journey-generator/SKILL.md +7 -0
  4. package/assets/skills/taphound-journey-generator/scripts/envelope.mjs +18 -1
  5. package/assets/skills/taphound-verify-change/references/preserve.md +15 -7
  6. package/assets/skills/taphound-verify-change/scripts/ui-refactor.mjs +1 -1
  7. package/dist/adapters/appium/appium-ui-snapshot-provider.d.ts +8 -0
  8. package/dist/adapters/appium/appium-ui-snapshot-provider.js +123 -54
  9. package/dist/adapters/filesystem/diagnostics-journal.d.ts +17 -0
  10. package/dist/adapters/filesystem/diagnostics-journal.js +130 -0
  11. package/dist/application/diagnostics/diagnostics-exporter.d.ts +29 -0
  12. package/dist/application/diagnostics/diagnostics-exporter.js +283 -0
  13. package/dist/application/diagnostics/ui-capture-telemetry.d.ts +16 -0
  14. package/dist/application/diagnostics/ui-capture-telemetry.js +81 -0
  15. package/dist/application/report/report-writer.d.ts +5 -0
  16. package/dist/application/report/report-writer.js +16 -0
  17. package/dist/application/runtime/verify-runtime.js +23 -1
  18. package/dist/application/ui/observed-ui-snapshot-provider.d.ts +15 -0
  19. package/dist/application/ui/observed-ui-snapshot-provider.js +66 -0
  20. package/dist/application/ui/ui-stability-probe.d.ts +6 -0
  21. package/dist/application/ui/ui-stability-probe.js +21 -5
  22. package/dist/application/wait/idle-waiter.js +19 -4
  23. package/dist/cli/commands/diagnose.d.ts +3 -0
  24. package/dist/cli/commands/diagnose.js +84 -0
  25. package/dist/cli/commands/verify.js +55 -3
  26. package/dist/cli/dependencies.d.ts +11 -0
  27. package/dist/cli/dependencies.js +58 -5
  28. package/dist/cli/diagnostics-recorder.d.ts +31 -0
  29. package/dist/cli/diagnostics-recorder.js +82 -0
  30. package/dist/cli/main.d.ts +1 -1
  31. package/dist/cli/main.js +58 -1
  32. package/dist/cli/program.js +3 -1
  33. package/dist/domain/contract.d.ts +6 -6
  34. package/dist/domain/diagnostics.d.ts +1396 -0
  35. package/dist/domain/diagnostics.js +193 -0
  36. package/dist/domain/failure.js +1 -1
  37. package/dist/domain/report.d.ts +3 -3
  38. package/dist/domain/verify-receipt.d.ts +17 -0
  39. package/dist/domain/verify-receipt.js +17 -0
  40. package/dist/domain/workspace.d.ts +2 -0
  41. package/dist/domain/workspace.js +2 -0
  42. package/dist/ports/diagnostics.d.ts +15 -0
  43. package/dist/ports/diagnostics.js +1 -0
  44. package/package.json +2 -2
@@ -0,0 +1,84 @@
1
+ import { resolve } from "node:path";
2
+ import { Command, InvalidArgumentError } from "commander";
3
+ import { exitCodeForFailure, failureCodeFromUnknown } from "../../domain/failure.js";
4
+ import { DIAGNOSTICS_EXPORT_DIR } from "../../domain/workspace.js";
5
+ import { errorMessage, failureOutput, writeJson, writeLine } from "../output.js";
6
+ const REVIEW_NOTE = "Review the file before sharing it. It holds command outcomes, "
7
+ + "timings, and failure codes; project paths, package, Activity and Journey "
8
+ + "names, locator values, device serials, screenshots, UI hierarchies, and "
9
+ + "log text are left out or replaced by aliases.";
10
+ function positiveInteger(value) {
11
+ const parsed = Number(value);
12
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 1000) {
13
+ throw new InvalidArgumentError("must be an integer from 1 to 1000");
14
+ }
15
+ return parsed;
16
+ }
17
+ function exportFileName(at) {
18
+ return `taphound-diagnostics-${at.toISOString().replaceAll(":", "-")}.json`;
19
+ }
20
+ export function createDiagnoseCommand(dependencies) {
21
+ return new Command("diagnose")
22
+ .description("Collect redacted diagnostics to attach to TapHound feedback")
23
+ .addCommand(new Command("export")
24
+ .description("Write a redacted diagnostics bundle from the local journal and recent reports")
25
+ .option("--project <path>", "Android project root", dependencies.cwd())
26
+ .option("--out <path>", `Bundle path (default ${DIAGNOSTICS_EXPORT_DIR}/<timestamp>.json)`)
27
+ .option("--events <count>", "Most recent journal events to include", positiveInteger, 50)
28
+ .option("--runs <count>", "Most recent referenced runs to summarize", positiveInteger, 10)
29
+ .option("--json", "Emit one machine-readable JSON value")
30
+ .action(async (options) => {
31
+ const json = options.json === true;
32
+ const diagnostics = dependencies.diagnosticsExport;
33
+ if (diagnostics === undefined) {
34
+ const output = failureOutput(2, "CONFIG_INVALID", "TapHound diagnostics export is not configured");
35
+ if (json) {
36
+ writeJson(dependencies.stdout, output);
37
+ }
38
+ else {
39
+ writeLine(dependencies.stderr, output.failure.message);
40
+ }
41
+ dependencies.setExitCode(2);
42
+ return;
43
+ }
44
+ try {
45
+ const projectRoot = resolve(dependencies.cwd(), options.project);
46
+ const bundle = await diagnostics.export({
47
+ projectRoot,
48
+ eventLimit: options.events,
49
+ runLimit: options.runs
50
+ });
51
+ const path = options.out === undefined
52
+ ? resolve(projectRoot, DIAGNOSTICS_EXPORT_DIR, exportFileName(new Date(bundle.generatedAt)))
53
+ : resolve(dependencies.cwd(), options.out);
54
+ await diagnostics.write(path, `${JSON.stringify(bundle, null, 2)}\n`);
55
+ if (json) {
56
+ writeJson(dependencies.stdout, {
57
+ status: "exported",
58
+ exitCode: 0,
59
+ path,
60
+ events: bundle.journal.events.length,
61
+ runs: bundle.runs.length
62
+ });
63
+ }
64
+ else {
65
+ writeLine(dependencies.stdout, `TapHound diagnostics: ${path}\n${String(bundle.journal.events.length)} event(s), `
66
+ + `${String(bundle.runs.length)} run summary(ies)`);
67
+ }
68
+ writeLine(dependencies.stderr, REVIEW_NOTE);
69
+ dependencies.setExitCode(0);
70
+ }
71
+ catch (error) {
72
+ const code = failureCodeFromUnknown(error) ?? "INTERNAL_ERROR";
73
+ const exitCode = exitCodeForFailure(code);
74
+ const output = failureOutput(exitCode, code, errorMessage(error));
75
+ if (json) {
76
+ writeJson(dependencies.stdout, output);
77
+ }
78
+ else {
79
+ writeLine(dependencies.stderr, output.failure.message);
80
+ }
81
+ dependencies.setExitCode(exitCode);
82
+ }
83
+ }));
84
+ }
@@ -1,12 +1,15 @@
1
- import { resolve } from "node:path";
1
+ import { createHash } from "node:crypto";
2
+ import { dirname, join, resolve } from "node:path";
2
3
  import { Command } from "commander";
3
4
  import { readCliVersion } from "../version.js";
4
5
  import { runDiffVerification } from "../diff-verification.js";
5
6
  import { loadPublishedReplayPolicy } from "../../application/generation/replay-policy-loader.js";
7
+ import { logcatEvidenceWarning } from "../../application/report/report-writer.js";
6
8
  import { TapHoundConfigSchema } from "../../domain/config.js";
7
9
  import { DEFAULT_DEVICE_ROLE, JourneySchema } from "../../domain/journey.js";
8
10
  import { assertArtifactDirectory, CONFIG_PATH } from "../../domain/workspace.js";
9
11
  import { exitCodeForFailure } from "../../domain/failure.js";
12
+ import { VERIFY_RECEIPT_FILE, VerifyReceiptSchema } from "../../domain/verify-receipt.js";
10
13
  import { errorMessage, failureOutput, writeJson, writeLine } from "../output.js";
11
14
  import { prepareWorkspace } from "../workspace-guard.js";
12
15
  function toolVersions(checks) {
@@ -26,6 +29,50 @@ function writeFailure(dependencies, json, code, message) {
26
29
  }
27
30
  dependencies.setExitCode(exitCode);
28
31
  }
32
+ /** The normalized invocation TapHound ran, recorded in the process receipt. */
33
+ function receiptArgv(options, projectRoot, deviceSerial) {
34
+ const root = resolve(projectRoot);
35
+ return [
36
+ "verify",
37
+ "--project", root,
38
+ "--config", resolve(root, options.config),
39
+ "--journey", resolve(root, options.journey),
40
+ "--device", deviceSerial,
41
+ ...(options.package === undefined ? [] : ["--package", options.package]),
42
+ ...(options.activity === undefined ? [] : ["--activity", options.activity]),
43
+ ...(options.reports === undefined ? [] : ["--reports", options.reports]),
44
+ ...(options.policyFromMeta === true ? ["--policy-from-meta"] : []),
45
+ ...(options.json === true ? ["--json"] : [])
46
+ ];
47
+ }
48
+ /**
49
+ * Publishes the process receipt beside the report. The report is already
50
+ * published, so a receipt failure is reported on stderr and leaves the
51
+ * verify result intact; a Workflow checker then pauses on the missing receipt.
52
+ */
53
+ async function writeReceipt(dependencies, argv, result) {
54
+ if (dependencies.writeVerifyReceipt === undefined)
55
+ return undefined;
56
+ const receiptPath = join(dirname(result.reportPath), VERIFY_RECEIPT_FILE);
57
+ try {
58
+ const receipt = VerifyReceiptSchema.parse({
59
+ version: 1,
60
+ argv,
61
+ exitCode: result.exitCode,
62
+ journeySha256: result.report.journey.sha256,
63
+ reportPath: result.reportPath,
64
+ reportSha256: createHash("sha256")
65
+ .update(await dependencies.readFile(result.reportPath))
66
+ .digest("hex")
67
+ });
68
+ await dependencies.writeVerifyReceipt(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
69
+ return receiptPath;
70
+ }
71
+ catch (error) {
72
+ writeLine(dependencies.stderr, `TapHound: verify receipt was not written: ${errorMessage(error)}`);
73
+ return undefined;
74
+ }
75
+ }
29
76
  async function runDoctorAndVerify(dependencies, options, config, journey, projectRoot) {
30
77
  const json = options.json === true;
31
78
  let replayPolicy;
@@ -103,11 +150,16 @@ async function runDoctorAndVerify(dependencies, options, config, journey, projec
103
150
  ? {}
104
151
  : { signal: dependencies.signal })
105
152
  });
153
+ const receiptPath = await writeReceipt(dependencies, receiptArgv(options, projectRoot, deviceSerial), result);
106
154
  if (json) {
107
- writeJson(dependencies.stdout, result);
155
+ writeJson(dependencies.stdout, receiptPath === undefined ? result : { ...result, receiptPath });
108
156
  }
109
157
  else {
110
- writeLine(dependencies.stdout, `TapHound verify: ${result.status.toUpperCase()}\nReport: ${result.reportPath}`);
158
+ writeLine(dependencies.stdout, `TapHound verify: ${result.status.toUpperCase()}\nReport: ${result.reportPath}${receiptPath === undefined ? "" : `\nReceipt: ${receiptPath}`}`);
159
+ }
160
+ const logcatWarning = logcatEvidenceWarning(result.report);
161
+ if (logcatWarning !== undefined) {
162
+ writeLine(dependencies.stderr, logcatWarning);
111
163
  }
112
164
  dependencies.setExitCode(result.exitCode);
113
165
  }
@@ -1,3 +1,6 @@
1
+ import { type DiagnosticsExportInput } from "../application/diagnostics/diagnostics-exporter.js";
2
+ import type { DiagnosticsBundle } from "../domain/diagnostics.js";
3
+ import { type CliDiagnostics } from "./diagnostics-recorder.js";
1
4
  import type { MobileMcpTools } from "../adapters/runtime/mobile-mcp/mobile-mcp-tools.js";
2
5
  import type { Clock } from "../ports/clock.js";
3
6
  import { type AlignCameraResult } from "../application/align/align-service.js";
@@ -160,11 +163,19 @@ export interface CliDependencies {
160
163
  } | undefined;
161
164
  readJson: (path: string) => Promise<unknown>;
162
165
  readFile: (path: string) => Promise<Buffer>;
166
+ /** Atomically publishes a verify process receipt beside its report. */
167
+ writeVerifyReceipt?: ((path: string, content: string) => Promise<void>) | undefined;
163
168
  cwd: () => string;
164
169
  stdout: TextOutput;
165
170
  stderr: TextOutput;
166
171
  setExitCode: (code: number) => void;
167
172
  close?: (() => Promise<void>) | undefined;
173
+ /** Local diagnostics journal; absent in tests and when disabled. */
174
+ diagnostics?: CliDiagnostics | undefined;
175
+ diagnosticsExport?: {
176
+ export: (input: DiagnosticsExportInput) => Promise<DiagnosticsBundle>;
177
+ write: (path: string, content: string) => Promise<void>;
178
+ } | undefined;
168
179
  }
169
180
  export interface ProductionDependencyOptions {
170
181
  generationStoreFactory?: (projectRoot: string) => GenerationSessionStore;
@@ -1,5 +1,5 @@
1
1
  import { randomBytes, randomUUID } from "node:crypto";
2
- import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { homedir, tmpdir } from "node:os";
4
4
  import { dirname, join, resolve as resolvePath } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
@@ -8,6 +8,12 @@ import { AdbRuntimeBackend } from "../adapters/runtime/adb-runtime-backend.js";
8
8
  import { SharedSessionRuntimeBackend } from "../adapters/runtime/shared-session-runtime-backend.js";
9
9
  import { SessionBackedScreenshotAdapter, SessionBackedUiSnapshotProviderFactory } from "../adapters/runtime/session-backed-ports.js";
10
10
  import { runtimeSessionPortViews } from "../adapters/runtime/session-adb-view.js";
11
+ import { FileSystemDiagnosticsJournal } from "../adapters/filesystem/diagnostics-journal.js";
12
+ import { UiCaptureTelemetry } from "../application/diagnostics/ui-capture-telemetry.js";
13
+ import { ObservedUiSnapshotProviderFactory } from "../application/ui/observed-ui-snapshot-provider.js";
14
+ import { DiagnosticsExporter } from "../application/diagnostics/diagnostics-exporter.js";
15
+ import { readCliVersion } from "./version.js";
16
+ import { diagnosticsEnabled, diagnosticsHost } from "./diagnostics-recorder.js";
11
17
  import { MobileMcpRuntimeBackend } from "../adapters/runtime/mobile-mcp/mobile-mcp-runtime-backend.js";
12
18
  import { McpToolClient } from "../adapters/runtime/mobile-mcp/mcp-tool-client.js";
13
19
  import { isMobileMcpSpawnFailureDetail, mobileMcpServerUnavailableMessage } from "../adapters/runtime/mobile-mcp/mobile-mcp-errors.js";
@@ -117,6 +123,9 @@ export function createProductionDependencies(signal, options = {}) {
117
123
  const clock = options.clock ?? new SystemClock();
118
124
  const now = () => clock.now();
119
125
  const permissionCaptureTimeoutMs = 10_000;
126
+ const uiTelemetry = new UiCaptureTelemetry();
127
+ const diagnosticsJournal = new FileSystemDiagnosticsJournal();
128
+ const observed = (source) => (new ObservedUiSnapshotProviderFactory(source, uiTelemetry, now));
120
129
  let backend;
121
130
  let screenshots;
122
131
  let uiSnapshots;
@@ -124,7 +133,7 @@ export function createProductionDependencies(signal, options = {}) {
124
133
  if (options.runtimeBackend !== undefined) {
125
134
  backend = options.runtimeBackend;
126
135
  screenshots = new SessionBackedScreenshotAdapter(backend);
127
- uiSnapshots = new CachedUiSnapshotProviderFactory(new SessionBackedUiSnapshotProviderFactory(backend), now);
136
+ uiSnapshots = new CachedUiSnapshotProviderFactory(observed(new SessionBackedUiSnapshotProviderFactory(backend)), now);
128
137
  }
129
138
  else if (backendId === "mobile-mcp") {
130
139
  const shared = new SharedSessionRuntimeBackend(new MobileMcpRuntimeBackend({
@@ -134,12 +143,16 @@ export function createProductionDependencies(signal, options = {}) {
134
143
  sharedBackend = shared;
135
144
  backend = shared;
136
145
  screenshots = new SessionBackedScreenshotAdapter(backend);
137
- uiSnapshots = new CachedUiSnapshotProviderFactory(new SessionBackedUiSnapshotProviderFactory(backend), now);
146
+ uiSnapshots = new CachedUiSnapshotProviderFactory(observed(new SessionBackedUiSnapshotProviderFactory(backend)), now);
138
147
  }
139
148
  else {
140
149
  const adbAdapter = new AdbAdapter(runner);
141
150
  const androidCli = new AndroidCliAdapter(runner);
142
- const autoSnapshots = new CachedUiSnapshotProviderFactory(new AutoUiSnapshotProviderFactory(new SystemUiAutomatorSnapshotProviderFactory(runner), new AndroidCliSnapshotProviderFactory(runner), new AppiumUiSnapshotProviderFactory(runner)), now);
151
+ const autoSnapshots = new CachedUiSnapshotProviderFactory(observed(new AutoUiSnapshotProviderFactory(new SystemUiAutomatorSnapshotProviderFactory(runner), new AndroidCliSnapshotProviderFactory(runner), new AppiumUiSnapshotProviderFactory(runner, undefined, {
152
+ onSessionRecovery: (succeeded) => {
153
+ uiTelemetry.sessionRecovered("appium-uiautomator2", succeeded);
154
+ }
155
+ }))), now);
143
156
  const adbBackend = new AdbRuntimeBackend({
144
157
  adb: adbAdapter,
145
158
  screenshots: androidCli,
@@ -585,6 +598,17 @@ export function createProductionDependencies(signal, options = {}) {
585
598
  : { cliEntryPath: process.argv[1] }),
586
599
  readJson: async (path) => JSON.parse(await readFile(path, "utf8")),
587
600
  readFile: async (path) => readFile(path),
601
+ writeVerifyReceipt: async (path, content) => {
602
+ const temporaryPath = `${path}.${randomUUID()}.tmp`;
603
+ try {
604
+ await writeFile(temporaryPath, content, { encoding: "utf8", flag: "wx" });
605
+ await rename(temporaryPath, path);
606
+ }
607
+ catch (error) {
608
+ await rm(temporaryPath, { force: true });
609
+ throw error;
610
+ }
611
+ },
588
612
  cwd: () => process.cwd(),
589
613
  stdout: {
590
614
  write: (content) => {
@@ -598,6 +622,35 @@ export function createProductionDependencies(signal, options = {}) {
598
622
  },
599
623
  setExitCode: (code) => {
600
624
  process.exitCode = code;
601
- }
625
+ },
626
+ diagnosticsExport: {
627
+ export: (input) => new DiagnosticsExporter({
628
+ journal: diagnosticsJournal,
629
+ readJson: async (path) => JSON.parse(await readFile(path, "utf8")),
630
+ now: () => new Date(),
631
+ taphoundVersion: readCliVersion(),
632
+ host: diagnosticsHost()
633
+ }).export(input),
634
+ write: async (path, content) => {
635
+ await mkdir(dirname(path), { recursive: true });
636
+ const temporaryPath = `${path}.${randomUUID()}.tmp`;
637
+ try {
638
+ await writeFile(temporaryPath, content, { encoding: "utf8", flag: "wx", mode: 0o600 });
639
+ await rename(temporaryPath, path);
640
+ }
641
+ catch (error) {
642
+ await rm(temporaryPath, { force: true });
643
+ throw error;
644
+ }
645
+ }
646
+ },
647
+ ...(diagnosticsEnabled(process.env)
648
+ ? {
649
+ diagnostics: {
650
+ journal: diagnosticsJournal,
651
+ telemetry: uiTelemetry
652
+ }
653
+ }
654
+ : {})
602
655
  };
603
656
  }
@@ -0,0 +1,31 @@
1
+ import type { Command } from "commander";
2
+ import { type CommandEvent, type DiagnosticsHostSchema } from "../domain/diagnostics.js";
3
+ import type { UiCaptureTelemetry } from "../application/diagnostics/ui-capture-telemetry.js";
4
+ import type { DiagnosticsJournal } from "../ports/diagnostics.js";
5
+ import type { z } from "zod";
6
+ export interface CliDiagnostics {
7
+ journal: DiagnosticsJournal;
8
+ telemetry: UiCaptureTelemetry;
9
+ }
10
+ export declare function diagnosticsEnabled(env: Record<string, string | undefined>): boolean;
11
+ export declare function diagnosticsHost(): z.infer<typeof DiagnosticsHostSchema>;
12
+ /** What the invoked command is, captured before its action runs. */
13
+ export interface InvokedCommand {
14
+ command: string;
15
+ flags: string[];
16
+ projectRoot: string;
17
+ }
18
+ export declare function describeInvocation(action: Command, cwd: string): InvokedCommand;
19
+ /**
20
+ * Reads only the structured outcome of one machine-readable output: status,
21
+ * failure code, and run id. Messages and every other field are ignored.
22
+ */
23
+ export declare function outcomeFromJsonOutput(stdout: string): Pick<CommandEvent, "status" | "failureCode" | "runId">;
24
+ export declare function commandEvent(input: {
25
+ invocation: InvokedCommand;
26
+ startedAt: Date;
27
+ durationMs: number;
28
+ exitCode: number;
29
+ stdout: string | undefined;
30
+ telemetry: UiCaptureTelemetry;
31
+ }): CommandEvent;
@@ -0,0 +1,82 @@
1
+ import { arch, platform, version as nodeVersion } from "node:process";
2
+ import { resolve } from "node:path";
3
+ import { CommandEventSchema, DIAGNOSTICS_DISABLE_ENV_VAR } from "../domain/diagnostics.js";
4
+ import { FAILURE_CODES } from "../domain/failure.js";
5
+ import { readCliVersion } from "./version.js";
6
+ export function diagnosticsEnabled(env) {
7
+ const value = env[DIAGNOSTICS_DISABLE_ENV_VAR]?.trim().toLowerCase();
8
+ return !["0", "off", "false", "no"].includes(value ?? "");
9
+ }
10
+ export function diagnosticsHost() {
11
+ return {
12
+ platform,
13
+ arch,
14
+ node: nodeVersion.replace(/^v/, "")
15
+ };
16
+ }
17
+ export function describeInvocation(action, cwd) {
18
+ const names = [];
19
+ let command = action;
20
+ while (command.parent !== null) {
21
+ names.unshift(command.name());
22
+ command = command.parent;
23
+ }
24
+ const flags = action.options
25
+ .map((option) => option.attributeName())
26
+ .filter((name) => action.getOptionValueSource(name) === "cli");
27
+ const project = action.opts().project;
28
+ return {
29
+ command: names.join(" "),
30
+ flags,
31
+ projectRoot: typeof project === "string" ? resolve(cwd, project) : cwd
32
+ };
33
+ }
34
+ function record(value) {
35
+ return value !== null && typeof value === "object" && !Array.isArray(value)
36
+ ? value
37
+ : undefined;
38
+ }
39
+ function failureCode(value) {
40
+ return FAILURE_CODES.find((code) => code === value);
41
+ }
42
+ /**
43
+ * Reads only the structured outcome of one machine-readable output: status,
44
+ * failure code, and run id. Messages and every other field are ignored.
45
+ */
46
+ export function outcomeFromJsonOutput(stdout) {
47
+ let parsed;
48
+ try {
49
+ parsed = JSON.parse(stdout.trim());
50
+ }
51
+ catch {
52
+ return {};
53
+ }
54
+ const output = record(parsed);
55
+ if (output === undefined)
56
+ return {};
57
+ const report = record(output.report);
58
+ const status = [output.status, output.verdict].find((value) => (typeof value === "string" && /^[a-zA-Z][a-zA-Z\d]{0,31}$/.test(value)));
59
+ const code = failureCode(record(output.failure)?.code)
60
+ ?? failureCode(record(report?.primaryFailure)?.code);
61
+ const runId = [report?.runId, output.runId].find((value) => (typeof value === "string" && /^[\w.-]{1,128}$/.test(value)));
62
+ return {
63
+ ...(typeof status === "string" ? { status } : {}),
64
+ ...(code === undefined ? {} : { failureCode: code }),
65
+ ...(typeof runId === "string" ? { runId } : {})
66
+ };
67
+ }
68
+ export function commandEvent(input) {
69
+ return CommandEventSchema.parse({
70
+ version: 1,
71
+ kind: "command",
72
+ at: input.startedAt.toISOString(),
73
+ taphoundVersion: readCliVersion(),
74
+ host: diagnosticsHost(),
75
+ command: input.invocation.command,
76
+ flags: input.invocation.flags,
77
+ durationMs: Math.max(0, Math.round(input.durationMs)),
78
+ exitCode: input.exitCode,
79
+ ...(input.stdout === undefined ? {} : outcomeFromJsonOutput(input.stdout)),
80
+ ui: input.telemetry.summary()
81
+ });
82
+ }
@@ -5,4 +5,4 @@ export interface TerminationSignalSource {
5
5
  off: (event: "SIGINT" | "SIGTERM", listener: () => void) => unknown;
6
6
  }
7
7
  export declare function withTerminationSignal<T>(operation: (signal: AbortSignal) => Promise<T>, source?: TerminationSignalSource): Promise<T>;
8
- export declare function runMain(argv: readonly string[], dependencies?: CliDependencies): Promise<void>;
8
+ export declare function runMain(argv: readonly string[], baseDependencies?: CliDependencies): Promise<void>;
package/dist/cli/main.js CHANGED
@@ -6,6 +6,7 @@ import { pathToFileURL } from "node:url";
6
6
  import { CommanderError } from "commander";
7
7
  import { createProductionDependencies } from "./dependencies.js";
8
8
  import { RuntimeBackendConfigError, RuntimeBackendSelectionError, resolveRuntimeBackendChoiceFromInvocation } from "./runtime-selection.js";
9
+ import { commandEvent, describeInvocation } from "./diagnostics-recorder.js";
9
10
  import { errorMessage, failureOutput, writeJson, writeLine } from "./output.js";
10
11
  import { createProgram } from "./program.js";
11
12
  export async function withTerminationSignal(operation, source = process) {
@@ -43,11 +44,64 @@ function overrideCommandExits(command) {
43
44
  overrideCommandExits(child);
44
45
  }
45
46
  }
46
- export async function runMain(argv, dependencies = createProductionDependencies()) {
47
+ /**
48
+ * Wraps the dependencies so the finished invocation can be journaled: the
49
+ * last exit code, and stdout only when the command promised one JSON value.
50
+ */
51
+ function observeInvocation(dependencies, json) {
52
+ let exitCode = 0;
53
+ let stdout = "";
54
+ return {
55
+ dependencies: {
56
+ ...dependencies,
57
+ stdout: {
58
+ write: (content) => {
59
+ if (json)
60
+ stdout += content;
61
+ dependencies.stdout.write(content);
62
+ }
63
+ },
64
+ setExitCode: (code) => {
65
+ exitCode = code;
66
+ dependencies.setExitCode(code);
67
+ }
68
+ },
69
+ exitCode: () => exitCode,
70
+ stdout: () => (json ? stdout : undefined)
71
+ };
72
+ }
73
+ async function journalInvocation(dependencies, invocation, startedAt, observed) {
74
+ const diagnostics = dependencies.diagnostics;
75
+ if (diagnostics === undefined || invocation === undefined)
76
+ return;
77
+ try {
78
+ await diagnostics.journal.append(invocation.projectRoot, commandEvent({
79
+ invocation,
80
+ startedAt,
81
+ durationMs: Date.now() - startedAt.getTime(),
82
+ exitCode: observed.exitCode(),
83
+ stdout: observed.stdout(),
84
+ telemetry: diagnostics.telemetry
85
+ }));
86
+ }
87
+ catch {
88
+ // The journal is best-effort evidence; it never changes a command result.
89
+ }
90
+ }
91
+ export async function runMain(argv, baseDependencies = createProductionDependencies()) {
47
92
  const json = argv.includes("--json");
93
+ const startedAt = new Date();
94
+ const observed = observeInvocation(baseDependencies, json);
95
+ const dependencies = baseDependencies.diagnostics === undefined
96
+ ? baseDependencies
97
+ : observed.dependencies;
98
+ let invocation;
48
99
  try {
49
100
  const program = createProgram(dependencies);
50
101
  overrideCommandExits(program);
102
+ program.hook("preAction", (_program, action) => {
103
+ invocation = describeInvocation(action, dependencies.cwd());
104
+ });
51
105
  await program.parseAsync([...argv]);
52
106
  }
53
107
  catch (error) {
@@ -67,6 +121,9 @@ export async function runMain(argv, dependencies = createProductionDependencies(
67
121
  }
68
122
  dependencies.setExitCode(exitCode);
69
123
  }
124
+ finally {
125
+ await journalInvocation(dependencies, invocation, startedAt, observed);
126
+ }
70
127
  }
71
128
  const entryPath = process.argv[1];
72
129
  if (entryPath !== undefined
@@ -15,6 +15,7 @@ import { createImpactCommand } from "./commands/impact.js";
15
15
  import { createKnowledgeCommand } from "./commands/knowledge.js";
16
16
  import { createBaselineCommand } from "./commands/baseline.js";
17
17
  import { createFailureCommand } from "./commands/failure.js";
18
+ import { createDiagnoseCommand } from "./commands/diagnose.js";
18
19
  import { createProductionDependencies } from "./dependencies.js";
19
20
  export function createProgram(dependencies = createProductionDependencies()) {
20
21
  const configureOutput = (command) => {
@@ -57,5 +58,6 @@ export function createProgram(dependencies = createProductionDependencies()) {
57
58
  .addCommand(configureOutput(createFailureCommand(dependencies)))
58
59
  .addCommand(configureOutput(createInitCommand(dependencies)))
59
60
  .addCommand(configureOutput(createAlignCommand(dependencies)))
60
- .addCommand(configureOutput(createImpactCommand(dependencies)));
61
+ .addCommand(configureOutput(createImpactCommand(dependencies)))
62
+ .addCommand(configureOutput(createDiagnoseCommand(dependencies)));
61
63
  }
@@ -214,8 +214,8 @@ export declare const ContractPreconditionOutcomeSchema: z.ZodObject<{
214
214
  status: z.ZodEnum<{
215
215
  passed: "passed";
216
216
  failed: "failed";
217
- unresolved: "unresolved";
218
217
  notRun: "notRun";
218
+ unresolved: "unresolved";
219
219
  }>;
220
220
  message: z.ZodOptional<z.ZodString>;
221
221
  }, z.core.$strict>;
@@ -228,8 +228,8 @@ export declare const ContractAssertionOutcomeSchema: z.ZodObject<{
228
228
  status: z.ZodEnum<{
229
229
  passed: "passed";
230
230
  failed: "failed";
231
- unresolved: "unresolved";
232
231
  notRun: "notRun";
232
+ unresolved: "unresolved";
233
233
  }>;
234
234
  message: z.ZodOptional<z.ZodString>;
235
235
  }, z.core.$strict>;
@@ -248,8 +248,8 @@ export declare const ContractCheckpointOutcomeSchema: z.ZodObject<{
248
248
  status: z.ZodEnum<{
249
249
  passed: "passed";
250
250
  failed: "failed";
251
- unresolved: "unresolved";
252
251
  notRun: "notRun";
252
+ unresolved: "unresolved";
253
253
  }>;
254
254
  message: z.ZodOptional<z.ZodString>;
255
255
  }, z.core.$strict>;
@@ -294,8 +294,8 @@ export declare const ContractVerdictViewSchema: z.ZodObject<{
294
294
  status: z.ZodEnum<{
295
295
  passed: "passed";
296
296
  failed: "failed";
297
- unresolved: "unresolved";
298
297
  notRun: "notRun";
298
+ unresolved: "unresolved";
299
299
  }>;
300
300
  message: z.ZodOptional<z.ZodString>;
301
301
  }, z.core.$strict>>;
@@ -308,8 +308,8 @@ export declare const ContractVerdictViewSchema: z.ZodObject<{
308
308
  status: z.ZodEnum<{
309
309
  passed: "passed";
310
310
  failed: "failed";
311
- unresolved: "unresolved";
312
311
  notRun: "notRun";
312
+ unresolved: "unresolved";
313
313
  }>;
314
314
  message: z.ZodOptional<z.ZodString>;
315
315
  }, z.core.$strict>>;
@@ -318,8 +318,8 @@ export declare const ContractVerdictViewSchema: z.ZodObject<{
318
318
  status: z.ZodEnum<{
319
319
  passed: "passed";
320
320
  failed: "failed";
321
- unresolved: "unresolved";
322
321
  notRun: "notRun";
322
+ unresolved: "unresolved";
323
323
  }>;
324
324
  message: z.ZodOptional<z.ZodString>;
325
325
  }, z.core.$strict>>>;