@vincentt-xr/cli 0.5.1 → 0.7.0
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/dist/cli/args.d.ts +2 -0
- package/dist/cli/args.js +5 -1
- package/dist/cli/index.js +110 -6
- package/dist/dx/clusters.d.ts +41 -0
- package/dist/dx/clusters.js +71 -0
- package/dist/dx/command.d.ts +50 -0
- package/dist/dx/command.js +167 -0
- package/dist/dx/journal.d.ts +51 -0
- package/dist/dx/journal.js +129 -0
- package/dist/dx/nudge.d.ts +7 -0
- package/dist/dx/nudge.js +54 -0
- package/dist/dx/send.d.ts +70 -0
- package/dist/dx/send.js +108 -0
- package/dist/dx/skeleton.d.ts +12 -0
- package/dist/dx/skeleton.js +138 -0
- package/dist/lib/feedback.js +12 -3
- package/dist/lib/lifecycle.js +7 -2
- package/dist/lib/relayInfo.d.ts +23 -0
- package/dist/lib/relayInfo.js +20 -2
- package/dist/preview/copy.d.ts +12 -0
- package/dist/preview/copy.js +22 -0
- package/dist/preview/runner.d.ts +13 -0
- package/dist/preview/runner.js +8 -3
- package/package.json +1 -1
package/dist/cli/args.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ export declare function isHelpInvocation(args: string[]): boolean;
|
|
|
13
13
|
* skip a publish the creator asked for. Anywhere else it stays an unknown flag.
|
|
14
14
|
*/
|
|
15
15
|
export declare function isVersionInvocation(args: string[]): boolean;
|
|
16
|
+
/** Flags that stand alone; every other `--flag` consumes the next argument. */
|
|
17
|
+
export declare const BOOLEAN_FLAGS: Set<string>;
|
|
16
18
|
export declare function refusedFlag(args: string[]): string | undefined;
|
|
17
19
|
/**
|
|
18
20
|
* The bare arguments, with flag VALUES removed. Without this, `vincentt link --dir
|
package/dist/cli/args.js
CHANGED
|
@@ -18,7 +18,7 @@ export function isVersionInvocation(args) {
|
|
|
18
18
|
return args[0] === "--version" || args[0] === "-v";
|
|
19
19
|
}
|
|
20
20
|
/** Flags that stand alone; every other `--flag` consumes the next argument. */
|
|
21
|
-
const BOOLEAN_FLAGS = new Set([
|
|
21
|
+
export const BOOLEAN_FLAGS = new Set([
|
|
22
22
|
"--force",
|
|
23
23
|
"--preview",
|
|
24
24
|
"--json",
|
|
@@ -26,6 +26,10 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
26
26
|
"--wait",
|
|
27
27
|
"--watch",
|
|
28
28
|
"--stop",
|
|
29
|
+
"--restart",
|
|
30
|
+
"--no-qr",
|
|
31
|
+
"--verbose",
|
|
32
|
+
"--send",
|
|
29
33
|
"--no-install",
|
|
30
34
|
"--allow-custom-api",
|
|
31
35
|
"--yes",
|
package/dist/cli/index.js
CHANGED
|
@@ -31,10 +31,16 @@ import { consoleVerbCommand, isConsoleOnlyVerb } from "../publish/consoleVerbs.j
|
|
|
31
31
|
import { PreviewUi } from "../preview/command.js";
|
|
32
32
|
import { httpRelayClient } from "../lib/diagnostics.js";
|
|
33
33
|
import { readLivePreview } from "../lib/relayInfo.js";
|
|
34
|
-
import { mintRateLimited, notSignedIn, previewConflict, startBlock, } from "../preview/copy.js";
|
|
34
|
+
import { attachedToRunning, mintRateLimited, notSignedIn, previewConflict, startBlock, } from "../preview/copy.js";
|
|
35
35
|
import { renderTerminalQr, shouldRenderQr } from "../preview/qr.js";
|
|
36
36
|
import { PreviewConflictError, PreviewRateLimitedError } from "../preview/api.js";
|
|
37
37
|
import { fetchNext, readCursor, writeCursor } from "../lib/feedback.js";
|
|
38
|
+
import { record as recordInvocation } from "../dx/journal.js";
|
|
39
|
+
import { dxSendCommand, parseDxOptions, runDx } from "../dx/command.js";
|
|
40
|
+
import { journalEnabled, journalPath, readJournal } from "../dx/journal.js";
|
|
41
|
+
import { postReport } from "../dx/send.js";
|
|
42
|
+
import { nudgeFor } from "../dx/nudge.js";
|
|
43
|
+
import { verbOf } from "../dx/skeleton.js";
|
|
38
44
|
import { isHelpInvocation, isVersionInvocation, positionalArgs, refusedFlag, } from "./args.js";
|
|
39
45
|
// The console origin serves /cli-auth (the browser target for `vincentt login`). VINCENTT_
|
|
40
46
|
// CONSOLE_URL overrides for dev/dogfood; VINCENTT_EDITOR_URL is honored as a legacy alias.
|
|
@@ -305,8 +311,23 @@ async function preview() {
|
|
|
305
311
|
// treated as absent, which lands on the mint the creator actually asked for.
|
|
306
312
|
const existing = await readLivePreview(cwd);
|
|
307
313
|
if (existing !== undefined) {
|
|
308
|
-
|
|
309
|
-
|
|
314
|
+
if (has("--restart")) {
|
|
315
|
+
// Asked for explicitly, so the old session is ended before the mint —
|
|
316
|
+
// otherwise the server's supersede rule would race the new one.
|
|
317
|
+
await previewStopSignal(cwd);
|
|
318
|
+
}
|
|
319
|
+
else if (filterTester === undefined && filterViewer === undefined) {
|
|
320
|
+
// No filter means the caller wanted the preview, not a diagnostics stream:
|
|
321
|
+
// hand them the link and exit rather than blocking in the follow loop.
|
|
322
|
+
// Following a live preview is what `vincentt logs`/`network`/`trace` are.
|
|
323
|
+
for (const l of attachedToRunning(existing.url, existing.expiresAt))
|
|
324
|
+
console.log(l);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
await followPreview(existing, ui, filterTester, filterViewer);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
310
331
|
}
|
|
311
332
|
let ending = null;
|
|
312
333
|
let resolveDone;
|
|
@@ -402,6 +423,10 @@ async function preview() {
|
|
|
402
423
|
*/
|
|
403
424
|
async function followPreview(info, ui, filterTester, filterViewer) {
|
|
404
425
|
console.log("→ Attaching to the preview running in this folder");
|
|
426
|
+
// The link, on its own line: a caller who is following a tester still needs to
|
|
427
|
+
// know which preview they landed on, and re-running to find out is the loop
|
|
428
|
+
// this printing exists to break.
|
|
429
|
+
console.log(info.url);
|
|
405
430
|
const following = filterTester ?? filterViewer;
|
|
406
431
|
if (following !== undefined) {
|
|
407
432
|
console.log(`✓ Following ${following}`);
|
|
@@ -555,6 +580,42 @@ async function version() {
|
|
|
555
580
|
for (const line of renderVersion(report))
|
|
556
581
|
console.log(line);
|
|
557
582
|
}
|
|
583
|
+
// ---- dx ---------------------------------------------------------------------
|
|
584
|
+
async function dx() {
|
|
585
|
+
const options = parseDxOptions(argv);
|
|
586
|
+
if (!options.send) {
|
|
587
|
+
process.exitCode = runDx(argv, (l) => console.log(l));
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
// `--send` is the one path out of this machine, so it resolves auth the way
|
|
591
|
+
// every other authed verb does and refuses rather than guessing.
|
|
592
|
+
process.exitCode = await dxSendCommand({
|
|
593
|
+
entries: readJournal,
|
|
594
|
+
print: (l) => console.log(l),
|
|
595
|
+
now: () => Date.now(),
|
|
596
|
+
enabled: () => journalEnabled(),
|
|
597
|
+
path: journalPath,
|
|
598
|
+
cliVersion,
|
|
599
|
+
auth: async () => {
|
|
600
|
+
// resolveConfig THROWS when there is no PAT, and its message is written
|
|
601
|
+
// for a verb that cannot proceed at all. Sending a DX report is
|
|
602
|
+
// optional, so a signed-out creator gets one plain line from
|
|
603
|
+
// dxSendCommand instead of a config-file stack trace.
|
|
604
|
+
try {
|
|
605
|
+
const cfg = await resolveConfig(projectDir(), {
|
|
606
|
+
allowCustomApi: has("--allow-custom-api"),
|
|
607
|
+
});
|
|
608
|
+
return cfg.pat ? { apiUrl: cfg.apiUrl, pat: cfg.pat } : undefined;
|
|
609
|
+
}
|
|
610
|
+
catch {
|
|
611
|
+
return undefined;
|
|
612
|
+
}
|
|
613
|
+
},
|
|
614
|
+
post: (apiUrl, pat, report) => postReport(apiUrl, pat, report),
|
|
615
|
+
confirm: (q) => stdinConfirm()(`${q} [y/N] `, false),
|
|
616
|
+
interactive: () => Boolean(process.stdin.isTTY),
|
|
617
|
+
}, options);
|
|
618
|
+
}
|
|
558
619
|
// ---- servers ----------------------------------------------------------------
|
|
559
620
|
async function relay() {
|
|
560
621
|
const { startRelay } = await import("../relay/server.js");
|
|
@@ -593,7 +654,8 @@ function usage() {
|
|
|
593
654
|
` publish Upload the built dist; go live at <slug>.vincentt.app\n` +
|
|
594
655
|
` [--dist <path>] [--note <s>] [--yes]\n` +
|
|
595
656
|
` preview Serve the app at an https link a phone can open\n` +
|
|
596
|
-
` [--port <n>] [--tester <id>] [--no-qr] [--stop]\n
|
|
657
|
+
` [--port <n>] [--tester <id>] [--no-qr] [--stop]\n` +
|
|
658
|
+
` Re-running prints the live link; --restart mints a new one.\n\n` +
|
|
597
659
|
`Diagnostics (need a running preview)\n` +
|
|
598
660
|
` logs [--errors] Console logs from the device [--json] [--tester <id>]\n` +
|
|
599
661
|
` network fetch/XHR requests [--json] [--tester <id>]\n` +
|
|
@@ -607,7 +669,11 @@ function usage() {
|
|
|
607
669
|
`Servers\n` +
|
|
608
670
|
` relay Run the diagnostics/annotation relay\n\n` +
|
|
609
671
|
`Meta\n` +
|
|
610
|
-
` version CLI version, and the API this machine resolves to\n
|
|
672
|
+
` version CLI version, and the API this machine resolves to\n` +
|
|
673
|
+
` dx Where this dev loop fought you [--days <n>] [--json]\n` +
|
|
674
|
+
` Reads a local journal of your own runs. Local by default.\n` +
|
|
675
|
+
` --send reports the findings to the Vincentt team; it\n` +
|
|
676
|
+
` shows the exact payload and asks first. [--note <text>]\n\n` +
|
|
611
677
|
`Common options: --dir <path> (project dir, default cwd)\n` +
|
|
612
678
|
` --allow-custom-api honor a binding's apiUrl override\n`);
|
|
613
679
|
}
|
|
@@ -655,6 +721,8 @@ async function main() {
|
|
|
655
721
|
return feedback();
|
|
656
722
|
case "relay":
|
|
657
723
|
return relay();
|
|
724
|
+
case "dx":
|
|
725
|
+
return dx();
|
|
658
726
|
default:
|
|
659
727
|
// Flow L30: rolling back and taking an address offline are console
|
|
660
728
|
// actions, and a bare "unknown command" here would be the product failing
|
|
@@ -666,7 +734,43 @@ async function main() {
|
|
|
666
734
|
process.exitCode = 1;
|
|
667
735
|
}
|
|
668
736
|
}
|
|
669
|
-
|
|
737
|
+
// Every invocation is journaled here, at the one site that sees all of them —
|
|
738
|
+
// the verbs, both pre-switch gates, the console-only fallback and the unknown
|
|
739
|
+
// command. Recording is best-effort and local-only (see dx/journal.ts); it never
|
|
740
|
+
// changes what the CLI does or what it exits with.
|
|
741
|
+
const startedAt = Date.now();
|
|
742
|
+
function journal(exitCode, error) {
|
|
743
|
+
recordInvocation({
|
|
744
|
+
argv,
|
|
745
|
+
cliVersion: cliVersion(),
|
|
746
|
+
cwd: process.cwd(),
|
|
747
|
+
startedAt,
|
|
748
|
+
endedAt: Date.now(),
|
|
749
|
+
exitCode,
|
|
750
|
+
error,
|
|
751
|
+
});
|
|
752
|
+
// The hint goes out AFTER the run it is about, so it reads as a footnote to
|
|
753
|
+
// what just happened rather than a warning about what is about to. Never on
|
|
754
|
+
// `dx` itself — telling someone reading the friction report that they could
|
|
755
|
+
// read the friction report is noise.
|
|
756
|
+
if (verbOf(argv) === "dx")
|
|
757
|
+
return;
|
|
758
|
+
try {
|
|
759
|
+
for (const l of nudgeFor(readJournal(), Date.now()))
|
|
760
|
+
console.log(l);
|
|
761
|
+
}
|
|
762
|
+
catch {
|
|
763
|
+
// A hint that can break a publish is not worth having.
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
main()
|
|
767
|
+
.then(() => {
|
|
768
|
+
// Verbs report failure by assigning process.exitCode and returning, so the
|
|
769
|
+
// resolved path is not necessarily the successful one.
|
|
770
|
+
journal(Number(process.exitCode ?? 0));
|
|
771
|
+
})
|
|
772
|
+
.catch((err) => {
|
|
670
773
|
console.error(`\n✗ ${err instanceof Error ? err.message : String(err)}`);
|
|
774
|
+
journal(1, err);
|
|
671
775
|
process.exit(1);
|
|
672
776
|
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Friction detection over journal entries.
|
|
3
|
+
*
|
|
4
|
+
* Two cluster types, because the two halves of "DX" fail differently:
|
|
5
|
+
*
|
|
6
|
+
* - `error` — the caller fought the tool and it pushed back. This is the
|
|
7
|
+
* classic retry loop (guessed a flag, got an error, tried again).
|
|
8
|
+
* - `repetition` — the caller ran the same thing repeatedly and it WORKED
|
|
9
|
+
* every time. No error is ever raised, so an error-keyed detector is blind
|
|
10
|
+
* to it, yet it is friction: the caller didn't know the first run had
|
|
11
|
+
* already given them what they needed. The reported case that motivated
|
|
12
|
+
* this file was exactly that shape — repeated `preview` runs, each one
|
|
13
|
+
* succeeding, each one minting a fresh link.
|
|
14
|
+
*
|
|
15
|
+
* Detection is deliberately loose. A false positive costs a line in a report a
|
|
16
|
+
* human is already reading; a miss costs a DX gap nobody ever hears about.
|
|
17
|
+
*/
|
|
18
|
+
import type { JournalEntry } from "./journal.js";
|
|
19
|
+
export type ClusterKind = "error" | "repetition";
|
|
20
|
+
export interface Cluster {
|
|
21
|
+
kind: ClusterKind;
|
|
22
|
+
skeleton: string;
|
|
23
|
+
entries: JournalEntry[];
|
|
24
|
+
/** Wall time from the first to the last call in the run, in ms. */
|
|
25
|
+
spanMs: number;
|
|
26
|
+
errors: number;
|
|
27
|
+
/** True when the run ended on a success — the caller got there eventually. */
|
|
28
|
+
recovered: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Group consecutive same-skeleton entries and keep the runs that look like
|
|
32
|
+
* friction. Entries are expected in chronological order.
|
|
33
|
+
*/
|
|
34
|
+
export declare function findClusters(entries: JournalEntry[]): Cluster[];
|
|
35
|
+
/**
|
|
36
|
+
* Rank by cost × recurrence: a cluster's cost is the calls it wasted, and a
|
|
37
|
+
* skeleton seen across several clusters is a recurring gap rather than a bad
|
|
38
|
+
* afternoon. Error clusters outrank repetition ones at equal weight — the
|
|
39
|
+
* caller was actively blocked rather than merely inefficient.
|
|
40
|
+
*/
|
|
41
|
+
export declare function rankClusters(clusters: Cluster[]): Cluster[];
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** A repetition run must be at least this long to count as friction. */
|
|
2
|
+
const REPETITION_MIN = 3;
|
|
3
|
+
/**
|
|
4
|
+
* How close together repeated calls must sit to read as one struggle. Three
|
|
5
|
+
* `preview` runs across a working day are three sessions; three inside ten
|
|
6
|
+
* minutes are one caller not getting what they needed.
|
|
7
|
+
*/
|
|
8
|
+
const REPETITION_WINDOW_MS = 10 * 60 * 1000;
|
|
9
|
+
function span(run) {
|
|
10
|
+
const first = Date.parse(run[0].ts);
|
|
11
|
+
const last = Date.parse(run[run.length - 1].ts);
|
|
12
|
+
return Number.isFinite(first) && Number.isFinite(last) ? last - first : 0;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Group consecutive same-skeleton entries and keep the runs that look like
|
|
16
|
+
* friction. Entries are expected in chronological order.
|
|
17
|
+
*/
|
|
18
|
+
export function findClusters(entries) {
|
|
19
|
+
const clusters = [];
|
|
20
|
+
for (let i = 0; i < entries.length;) {
|
|
21
|
+
const skel = entries[i].skel;
|
|
22
|
+
const run = [];
|
|
23
|
+
let j = i;
|
|
24
|
+
while (j < entries.length && entries[j].skel === skel)
|
|
25
|
+
run.push(entries[j++]);
|
|
26
|
+
const errors = run.filter((e) => e.exit !== 0).length;
|
|
27
|
+
const spanMs = span(run);
|
|
28
|
+
// A cluster needs at least two calls. A lone failure is usually the tool
|
|
29
|
+
// working — a signed-out `whoami`, a typo'd verb — and reporting each one
|
|
30
|
+
// buries the real findings under noise, which is the failure mode that
|
|
31
|
+
// trains a reader to skim past the report entirely.
|
|
32
|
+
if (errors > 0 && run.length >= 2) {
|
|
33
|
+
clusters.push({
|
|
34
|
+
kind: "error",
|
|
35
|
+
skeleton: skel,
|
|
36
|
+
entries: run,
|
|
37
|
+
spanMs,
|
|
38
|
+
errors,
|
|
39
|
+
recovered: run[run.length - 1].exit === 0,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
else if (errors === 0 &&
|
|
43
|
+
run.length >= REPETITION_MIN &&
|
|
44
|
+
spanMs <= REPETITION_WINDOW_MS) {
|
|
45
|
+
clusters.push({
|
|
46
|
+
kind: "repetition",
|
|
47
|
+
skeleton: skel,
|
|
48
|
+
entries: run,
|
|
49
|
+
spanMs,
|
|
50
|
+
errors: 0,
|
|
51
|
+
recovered: true,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
i = j;
|
|
55
|
+
}
|
|
56
|
+
return clusters;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Rank by cost × recurrence: a cluster's cost is the calls it wasted, and a
|
|
60
|
+
* skeleton seen across several clusters is a recurring gap rather than a bad
|
|
61
|
+
* afternoon. Error clusters outrank repetition ones at equal weight — the
|
|
62
|
+
* caller was actively blocked rather than merely inefficient.
|
|
63
|
+
*/
|
|
64
|
+
export function rankClusters(clusters) {
|
|
65
|
+
const recurrence = new Map();
|
|
66
|
+
for (const c of clusters) {
|
|
67
|
+
recurrence.set(c.skeleton, (recurrence.get(c.skeleton) ?? 0) + 1);
|
|
68
|
+
}
|
|
69
|
+
const weight = (c) => c.entries.length * (recurrence.get(c.skeleton) ?? 1) * (c.kind === "error" ? 2 : 1);
|
|
70
|
+
return [...clusters].sort((a, b) => weight(b) - weight(a));
|
|
71
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { JournalEntry } from "./journal.js";
|
|
2
|
+
import type { WireReport } from "./send.js";
|
|
3
|
+
export interface DxDeps {
|
|
4
|
+
entries: () => JournalEntry[];
|
|
5
|
+
print: (line: string) => void;
|
|
6
|
+
now: () => number;
|
|
7
|
+
enabled: () => boolean;
|
|
8
|
+
path: () => string;
|
|
9
|
+
}
|
|
10
|
+
export interface DxOptions {
|
|
11
|
+
/** Only consider runs from the last N days. */
|
|
12
|
+
days: number;
|
|
13
|
+
json: boolean;
|
|
14
|
+
/** Send the findings to the Vincentt team. Never implied by anything else. */
|
|
15
|
+
send: boolean;
|
|
16
|
+
/** Skip the confirmation prompt. Only meaningful with --send. */
|
|
17
|
+
yes: boolean;
|
|
18
|
+
/** The creator's own words about the friction, carried with the report. */
|
|
19
|
+
note?: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function parseDxOptions(args: string[]): DxOptions;
|
|
22
|
+
export declare function dxCommand(deps: DxDeps, options: DxOptions): number;
|
|
23
|
+
/** Wire the command to the real journal. */
|
|
24
|
+
export declare function runDx(args: string[], print: (line: string) => void): number;
|
|
25
|
+
export interface DxSendDeps extends DxDeps {
|
|
26
|
+
/** Resolved API host + PAT. Absent when the creator is not signed in. */
|
|
27
|
+
auth: () => Promise<{
|
|
28
|
+
apiUrl: string;
|
|
29
|
+
pat: string;
|
|
30
|
+
} | undefined>;
|
|
31
|
+
post: (apiUrl: string, pat: string, report: WireReport) => Promise<{
|
|
32
|
+
ref: string;
|
|
33
|
+
findings: number;
|
|
34
|
+
expiresAt: string;
|
|
35
|
+
}>;
|
|
36
|
+
/** Ask before sending. Returns false to abort. */
|
|
37
|
+
confirm: (question: string) => Promise<boolean>;
|
|
38
|
+
cliVersion: () => string;
|
|
39
|
+
/** True when stdin is a terminal — i.e. a human is here to answer. */
|
|
40
|
+
interactive: () => boolean;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* `vincentt dx --send`.
|
|
44
|
+
*
|
|
45
|
+
* The order is the consent model and is not an implementation detail: find the
|
|
46
|
+
* friction, BUILD the payload, SHOW it in full, ask, and only then send. A
|
|
47
|
+
* creator who cannot see what leaves their machine before it leaves has not
|
|
48
|
+
* agreed to anything.
|
|
49
|
+
*/
|
|
50
|
+
export declare function dxSendCommand(deps: DxSendDeps, options: DxOptions): Promise<number>;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { findClusters, rankClusters } from "./clusters.js";
|
|
2
|
+
import { journalEnabled, journalPath, readJournal } from "./journal.js";
|
|
3
|
+
import { buildReport, renderPayload } from "./send.js";
|
|
4
|
+
const DEFAULT_DAYS = 7;
|
|
5
|
+
export function parseDxOptions(args) {
|
|
6
|
+
const i = args.indexOf("--days");
|
|
7
|
+
const raw = i !== -1 ? Number(args[i + 1]) : NaN;
|
|
8
|
+
const n = args.indexOf("--note");
|
|
9
|
+
const note = n !== -1 ? args[n + 1] : undefined;
|
|
10
|
+
return {
|
|
11
|
+
days: Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_DAYS,
|
|
12
|
+
json: args.includes("--json"),
|
|
13
|
+
send: args.includes("--send"),
|
|
14
|
+
yes: args.includes("--yes"),
|
|
15
|
+
note: note !== undefined && !note.startsWith("-") ? note : undefined,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function withinDays(entries, days, now) {
|
|
19
|
+
const cutoff = now - days * 24 * 60 * 60 * 1000;
|
|
20
|
+
return entries.filter((e) => {
|
|
21
|
+
const t = Date.parse(e.ts);
|
|
22
|
+
return Number.isFinite(t) && t >= cutoff;
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
function seconds(ms) {
|
|
26
|
+
if (ms < 1000)
|
|
27
|
+
return `${ms}ms`;
|
|
28
|
+
const s = Math.round(ms / 1000);
|
|
29
|
+
if (s < 60)
|
|
30
|
+
return `${s}s`;
|
|
31
|
+
return `${Math.round(s / 60)}m`;
|
|
32
|
+
}
|
|
33
|
+
function describe(c) {
|
|
34
|
+
const head = c.kind === "error"
|
|
35
|
+
? `${c.entries.length}× ${c.skeleton} — ${c.errors} failed${c.recovered ? ", then succeeded" : ", never recovered"}`
|
|
36
|
+
: `${c.entries.length}× ${c.skeleton} — all succeeded, within ${seconds(c.spanMs)}`;
|
|
37
|
+
const why = c.kind === "error"
|
|
38
|
+
? c.recovered
|
|
39
|
+
? " The caller got there in the end. What did the first failure not tell them?"
|
|
40
|
+
: " The caller retried, then gave up. This one blocked real work."
|
|
41
|
+
: " Repeated with no error: the caller did not get what they needed the first time.";
|
|
42
|
+
const errs = [
|
|
43
|
+
...new Set(c.entries.map((e) => e.err).filter((e) => e !== null)),
|
|
44
|
+
];
|
|
45
|
+
const lines = [`• ${head}`, why];
|
|
46
|
+
if (errs.length > 0)
|
|
47
|
+
lines.push(` Errors: ${errs.join(", ")}`);
|
|
48
|
+
return lines;
|
|
49
|
+
}
|
|
50
|
+
export function dxCommand(deps, options) {
|
|
51
|
+
if (!deps.enabled()) {
|
|
52
|
+
deps.print("The DX journal is off (VINCENTT_DX_JOURNAL=0). Nothing is being recorded.");
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
const all = deps.entries();
|
|
56
|
+
if (all.length === 0) {
|
|
57
|
+
deps.print("No journal yet — it fills as you use the CLI.");
|
|
58
|
+
deps.print(` ${deps.path()}`);
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
const entries = withinDays(all, options.days, deps.now());
|
|
62
|
+
const clusters = rankClusters(findClusters(entries));
|
|
63
|
+
if (options.json) {
|
|
64
|
+
deps.print(JSON.stringify({ window: `${options.days}d`, runs: entries.length, clusters }, null, 2));
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
deps.print(`${entries.length} runs in the last ${options.days} days.`);
|
|
68
|
+
deps.print("");
|
|
69
|
+
if (clusters.length === 0) {
|
|
70
|
+
// A clean window is a real result. Manufacturing a finding to fill the
|
|
71
|
+
// report is how a signal becomes noise.
|
|
72
|
+
deps.print("No friction detected.");
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
deps.print(`${clusters.length} friction ${clusters.length === 1 ? "point" : "points"}, worst first:`);
|
|
76
|
+
deps.print("");
|
|
77
|
+
for (const c of clusters) {
|
|
78
|
+
for (const l of describe(c))
|
|
79
|
+
deps.print(l);
|
|
80
|
+
deps.print("");
|
|
81
|
+
}
|
|
82
|
+
deps.print("If an agent ran these, ask it what it was trying to do — the journal");
|
|
83
|
+
deps.print("records the shape of a run, not the intent behind it.");
|
|
84
|
+
}
|
|
85
|
+
// Printed on every report, findings or not: where the file is and that it
|
|
86
|
+
// stays put is the claim that lets this run without asking permission first,
|
|
87
|
+
// and a claim only made on some runs is one a reader cannot rely on.
|
|
88
|
+
deps.print("");
|
|
89
|
+
deps.print(`Journal: ${deps.path()} (local only, never sent)`);
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
/** Wire the command to the real journal. */
|
|
93
|
+
export function runDx(args, print) {
|
|
94
|
+
return dxCommand({
|
|
95
|
+
entries: readJournal,
|
|
96
|
+
print,
|
|
97
|
+
now: () => Date.now(),
|
|
98
|
+
enabled: () => journalEnabled(),
|
|
99
|
+
path: journalPath,
|
|
100
|
+
}, parseDxOptions(args));
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* `vincentt dx --send`.
|
|
104
|
+
*
|
|
105
|
+
* The order is the consent model and is not an implementation detail: find the
|
|
106
|
+
* friction, BUILD the payload, SHOW it in full, ask, and only then send. A
|
|
107
|
+
* creator who cannot see what leaves their machine before it leaves has not
|
|
108
|
+
* agreed to anything.
|
|
109
|
+
*/
|
|
110
|
+
export async function dxSendCommand(deps, options) {
|
|
111
|
+
if (!deps.enabled()) {
|
|
112
|
+
deps.print("The DX journal is off (VINCENTT_DX_JOURNAL=0). There is nothing to send.");
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
const entries = withinDays(deps.entries(), options.days, deps.now());
|
|
116
|
+
const clusters = rankClusters(findClusters(entries));
|
|
117
|
+
if (clusters.length === 0) {
|
|
118
|
+
// Nothing to report is a result, not a failure. Sending an empty report
|
|
119
|
+
// would cost the creator a round trip to tell us nothing.
|
|
120
|
+
deps.print(`No friction detected in the last ${options.days} days — nothing to send.`);
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
// --yes REQUIRES a terminal.
|
|
124
|
+
//
|
|
125
|
+
// The nudge prints into an agent's transcript, and an agent that reads it has
|
|
126
|
+
// every incentive to act: it just failed four times. Its reflex for an
|
|
127
|
+
// interactive prompt is to add --yes. That path ends with the AGENT deciding
|
|
128
|
+
// to transmit, supplying the note, and the human seeing the payload scroll
|
|
129
|
+
// past in a transcript they may not be reading — which is not the opt-in this
|
|
130
|
+
// feature is built around.
|
|
131
|
+
//
|
|
132
|
+
// Without --yes there is no such path: the prompt reads EOF under a pipe and
|
|
133
|
+
// fails closed. So the gate is narrow on purpose — it closes the one way an
|
|
134
|
+
// autonomous caller could consent on a creator's behalf, and leaves --yes
|
|
135
|
+
// working for the human who has read the payload.
|
|
136
|
+
if (options.yes && !deps.interactive()) {
|
|
137
|
+
deps.print("Sending a DX report is the creator's call, so --yes needs a terminal.");
|
|
138
|
+
deps.print("Run `vincentt dx --send` yourself, or drop --yes to be asked.");
|
|
139
|
+
return 1;
|
|
140
|
+
}
|
|
141
|
+
const auth = await deps.auth();
|
|
142
|
+
if (auth === undefined) {
|
|
143
|
+
deps.print("Not signed in. Run `vincentt login` to send a report.");
|
|
144
|
+
return 1;
|
|
145
|
+
}
|
|
146
|
+
const report = buildReport(clusters, deps.cliVersion(), options.note);
|
|
147
|
+
for (const l of renderPayload(report))
|
|
148
|
+
deps.print(l);
|
|
149
|
+
deps.print("");
|
|
150
|
+
if (!options.yes && !(await deps.confirm("Send this report?"))) {
|
|
151
|
+
deps.print("Not sent.");
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
const res = await deps.post(auth.apiUrl, auth.pat, report);
|
|
156
|
+
deps.print("");
|
|
157
|
+
deps.print(`✓ Reported — ${res.findings} ${res.findings === 1 ? "finding" : "findings"}, ref ${res.ref}`);
|
|
158
|
+
deps.print(` Kept until ${res.expiresAt}, then deleted.`);
|
|
159
|
+
deps.print(" Your journal stays on this machine.");
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
deps.print("");
|
|
164
|
+
deps.print(`✗ ${err instanceof Error ? err.message : String(err)}`);
|
|
165
|
+
return 1;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export interface JournalEntry {
|
|
2
|
+
/** ISO8601 UTC, invocation start. */
|
|
3
|
+
ts: string;
|
|
4
|
+
/** CLI version, so a finding can be dropped once the fix ships. */
|
|
5
|
+
v: string;
|
|
6
|
+
verb: string;
|
|
7
|
+
/** Flag names with values elided — the retry-cluster key. */
|
|
8
|
+
skel: string;
|
|
9
|
+
exit: number;
|
|
10
|
+
ms: number;
|
|
11
|
+
/** Error CLASS (`err.name`), never a message — messages carry user data. */
|
|
12
|
+
err: string | null;
|
|
13
|
+
/** Hash of the working tree, so runs group by project without recording where it is. */
|
|
14
|
+
wt: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Beside the machine config, so the journal shares the home the PAT already
|
|
18
|
+
* lives in — and so a run made outside any project (`login`, `version`) is still
|
|
19
|
+
* recorded, which a per-project file could not do.
|
|
20
|
+
*
|
|
21
|
+
* Resolved per call rather than captured at module load: the home directory is
|
|
22
|
+
* the seam tests move, and a frozen path would silently write to the real one.
|
|
23
|
+
*/
|
|
24
|
+
export declare function journalPath(): string;
|
|
25
|
+
/** `VINCENTT_DX_JOURNAL=0` turns recording off. A local file still needs an off switch. */
|
|
26
|
+
export declare function journalEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* A stable, non-reversible handle for "the folder this run happened in". Enough
|
|
29
|
+
* to group runs by project — which is what makes the one-session-per-tree
|
|
30
|
+
* friction visible — without recording anyone's directory layout.
|
|
31
|
+
*/
|
|
32
|
+
export declare function workingTreeId(cwd: string): string;
|
|
33
|
+
export interface RecordInput {
|
|
34
|
+
argv: string[];
|
|
35
|
+
cliVersion: string;
|
|
36
|
+
cwd: string;
|
|
37
|
+
startedAt: number;
|
|
38
|
+
endedAt: number;
|
|
39
|
+
exitCode: number;
|
|
40
|
+
error?: unknown;
|
|
41
|
+
}
|
|
42
|
+
export declare function buildEntry(input: RecordInput): JournalEntry;
|
|
43
|
+
/**
|
|
44
|
+
* Append one entry. Never throws: a journal that can break a publish is a
|
|
45
|
+
* liability, and every failure mode here (read-only home, full disk, a
|
|
46
|
+
* concurrent writer) is one the creator would rather have silently ignored than
|
|
47
|
+
* turned into a failed command.
|
|
48
|
+
*/
|
|
49
|
+
export declare function record(input: RecordInput): void;
|
|
50
|
+
/** Read the journal oldest-first. Malformed lines are skipped, not fatal. */
|
|
51
|
+
export declare function readJournal(): JournalEntry[];
|