knodin 0.5.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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +590 -0
  3. package/dist/bin/cli.js +1704 -0
  4. package/dist/src/agent-integration.js +250 -0
  5. package/dist/src/artifact-refresh.js +81 -0
  6. package/dist/src/cli-args.js +267 -0
  7. package/dist/src/cli-model.js +324 -0
  8. package/dist/src/compact-structural.js +96 -0
  9. package/dist/src/competitive-constraints.js +20 -0
  10. package/dist/src/competitive-manifest.js +330 -0
  11. package/dist/src/competitive-measurement.js +183 -0
  12. package/dist/src/competitive-runner.js +453 -0
  13. package/dist/src/competitive-sandbox.js +108 -0
  14. package/dist/src/context-export.js +422 -0
  15. package/dist/src/context.js +102 -0
  16. package/dist/src/docs-sections.js +141 -0
  17. package/dist/src/doctor.js +380 -0
  18. package/dist/src/engine/ann-hnsw.js +271 -0
  19. package/dist/src/engine/embeddings.js +193 -0
  20. package/dist/src/engine/file-walker.js +43 -0
  21. package/dist/src/engine/index.js +13030 -0
  22. package/dist/src/engine/perf.js +115 -0
  23. package/dist/src/engine/prune.js +112 -0
  24. package/dist/src/engine/source-policy.js +69 -0
  25. package/dist/src/engine/sqlite.js +71 -0
  26. package/dist/src/engine/symbol-delete.js +58 -0
  27. package/dist/src/failure-diagnosis.js +590 -0
  28. package/dist/src/fleet.js +7 -0
  29. package/dist/src/git-executable.js +31 -0
  30. package/dist/src/graph-query-health.js +115 -0
  31. package/dist/src/index-activity.js +125 -0
  32. package/dist/src/init-progress-worker.js +107 -0
  33. package/dist/src/init-progress.js +155 -0
  34. package/dist/src/init.js +985 -0
  35. package/dist/src/lifecycle-health.js +213 -0
  36. package/dist/src/lsp-readonly.js +217 -0
  37. package/dist/src/output-compression.js +629 -0
  38. package/dist/src/output-telemetry.js +359 -0
  39. package/dist/src/pr-triage.js +638 -0
  40. package/dist/src/relationship-adapters.js +370 -0
  41. package/dist/src/release-attestation.js +533 -0
  42. package/dist/src/repair-progress-worker.js +121 -0
  43. package/dist/src/repair-progress.js +262 -0
  44. package/dist/src/repository-init-process.js +173 -0
  45. package/dist/src/repository-management.js +1089 -0
  46. package/dist/src/response-budget.js +184 -0
  47. package/dist/src/server.js +53 -0
  48. package/dist/src/system-config.js +615 -0
  49. package/dist/src/terminal-help.js +83 -0
  50. package/dist/src/tools/knodin-tools.js +1438 -0
  51. package/dist/src/tools/reckon-tools.js +5 -0
  52. package/dist/src/update-policy.js +944 -0
  53. package/dist/src/update-trust.js +503 -0
  54. package/dist/src/version.js +13 -0
  55. package/dist/src/visualization.js +162 -0
  56. package/dist/src/wait-for-fresh.js +98 -0
  57. package/dist/src/worktree-lifecycle.js +231 -0
  58. package/docs/CLI.md +39 -0
  59. package/docs/COMMAND-OUTPUT-COMPRESSION.md +194 -0
  60. package/docs/DEAD-CODE-AND-IMPACT.md +27 -0
  61. package/docs/DOCTOR-AND-UPDATES.md +84 -0
  62. package/docs/INDEXING-POLICY-AND-PROVENANCE.md +37 -0
  63. package/docs/INSTALLATION.md +208 -0
  64. package/docs/MCP.md +100 -0
  65. package/docs/PT-ACCESS-RECOMMENDATION.md +91 -0
  66. package/docs/RELEASE-0.3-EVIDENCE.md +73 -0
  67. package/docs/REPOSITORIES-AND-WORKTREES.md +81 -0
  68. package/docs/SIGNED-UPDATES.md +146 -0
  69. package/docs/SYSTEMS-AND-RELATIONSHIPS.md +45 -0
  70. package/docs/TELEMETRY.md +42 -0
  71. package/docs/releases/0.3.0.md +46 -0
  72. package/docs/releases/0.4.0.md +68 -0
  73. package/docs/releases/0.4.1.md +28 -0
  74. package/docs/releases/0.4.2.md +27 -0
  75. package/docs/releases/0.4.3.md +23 -0
  76. package/docs/releases/0.5.0.md +29 -0
  77. package/package.json +110 -0
  78. package/schemas/release-attestation-v1.schema.json +210 -0
  79. package/tree-sitter-prisma.wasm +0 -0
  80. package/tree-sitter-sql.wasm +0 -0
  81. package/tree-sitter-xml.wasm +0 -0
@@ -0,0 +1,115 @@
1
+ function hasNonReconciliableDamage(graph) {
2
+ if (Object.values(graph.orphaned).some((count) => count > 0))
3
+ return true;
4
+ const lifecycleIssues = new Set(graph.lifecycle?.issues ?? []);
5
+ const reconcilablePrefixes = [
6
+ "indexed file no longer exists: ",
7
+ "indexed file is no longer eligible: ",
8
+ "indexed snapshot differs from disk: ",
9
+ ];
10
+ return graph.missing.records.some((record) => !lifecycleIssues.has(record) &&
11
+ !reconcilablePrefixes.some((prefix) => record.startsWith(prefix)));
12
+ }
13
+ function unavailableState(graph) {
14
+ if (graph.freshness?.state === "queued")
15
+ return "queued";
16
+ if (graph.freshness?.state === "indexing")
17
+ return "indexing";
18
+ if (graph.status === "indexing")
19
+ return "indexing";
20
+ if (graph.schemaVersion === 0)
21
+ return graph.coverage.sourceFiles === 0 ? "empty-repository" : "not-initialized";
22
+ if (graph.coverage.sourceFiles === 0)
23
+ return "empty-repository";
24
+ if (graph.coverage.indexedFiles === 0)
25
+ return "empty-index";
26
+ if (graph.status === "repair-needed" && hasNonReconciliableDamage(graph))
27
+ return "repair-needed";
28
+ return null;
29
+ }
30
+ export async function inspectGraphQueryHealth(repo, status) {
31
+ try {
32
+ const graph = await status(repo);
33
+ const state = unavailableState(graph);
34
+ if (!state)
35
+ return {
36
+ available: true,
37
+ state: graph.lifecycle && graph.lifecycle.status !== "healthy"
38
+ ? "lifecycle-degraded"
39
+ : "healthy",
40
+ graph,
41
+ };
42
+ return {
43
+ available: false,
44
+ status: "unavailable",
45
+ state,
46
+ graph,
47
+ remediation: graph.repairSteps,
48
+ };
49
+ }
50
+ catch (error) {
51
+ return {
52
+ available: false,
53
+ status: "unavailable",
54
+ state: "unknown",
55
+ remediation: ["Run `knodin doctor` and `knodin status --deep`."],
56
+ error: error instanceof Error ? error.message : String(error),
57
+ };
58
+ }
59
+ }
60
+ function hasNoMatches(value) {
61
+ if (Array.isArray(value.results))
62
+ return value.results.length === 0;
63
+ if (typeof value.count === "number")
64
+ return value.count === 0;
65
+ if (typeof value.changedFileCount === "number")
66
+ return value.changedFileCount === 0;
67
+ if (typeof value.summary === "string" && /\bnot found\b/i.test(value.summary))
68
+ return true;
69
+ if (typeof value.refused === "string" &&
70
+ /\b(?:not found|no edit sites|no matches)\b/i.test(value.refused))
71
+ return true;
72
+ return false;
73
+ }
74
+ /** Add an explicit healthy/no-match outcome without replacing operation data. */
75
+ export function decorateGraphQueryResult(value, graphState = "healthy", freshness) {
76
+ if (value === null || value === undefined) {
77
+ return {
78
+ status: "no-match",
79
+ result: value ?? null,
80
+ ...(freshness ? { freshness } : {}),
81
+ availability: { state: "no-match", graphState, outcome: "no-match" },
82
+ };
83
+ }
84
+ if (Array.isArray(value)) {
85
+ return {
86
+ status: value.length === 0 ? "no-match" : "ok",
87
+ results: value,
88
+ ...(freshness ? { freshness } : {}),
89
+ availability: {
90
+ state: value.length === 0 ? "no-match" : graphState,
91
+ graphState,
92
+ outcome: value.length === 0 ? "no-match" : "matches",
93
+ },
94
+ };
95
+ }
96
+ if (typeof value !== "object") {
97
+ return {
98
+ result: value,
99
+ ...(freshness ? { freshness } : {}),
100
+ availability: { state: graphState, outcome: "complete" },
101
+ };
102
+ }
103
+ const root = value;
104
+ const noMatch = hasNoMatches(root) && root.ambiguity === undefined;
105
+ return {
106
+ ...root,
107
+ ...(freshness ? { freshness } : {}),
108
+ ...(noMatch && root.status === undefined ? { status: "no-match" } : {}),
109
+ availability: {
110
+ state: noMatch ? "no-match" : graphState,
111
+ graphState,
112
+ outcome: noMatch ? "no-match" : "matches",
113
+ },
114
+ };
115
+ }
@@ -0,0 +1,125 @@
1
+ import child_process from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ function activityPath(repo) {
5
+ return path.join(path.resolve(repo), ".reckon", "index-activity.json");
6
+ }
7
+ const ACTIVITY_CLOCK_SKEW_MS = 2_000;
8
+ const MAX_ACTIVITY_AGE_MS = 7 * 24 * 60 * 60 * 1_000;
9
+ const INDEX_PHASES = new Set([
10
+ "starting",
11
+ "collecting-files",
12
+ "indexing-files",
13
+ "embedding-model",
14
+ "embedding-symbols",
15
+ "finalizing",
16
+ "verifying",
17
+ "completed",
18
+ ]);
19
+ function processIsAlive(pid) {
20
+ try {
21
+ process.kill(pid, 0);
22
+ return true;
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ function processStartedAt(pid) {
29
+ if (pid === process.pid)
30
+ return Date.now() - process.uptime() * 1_000;
31
+ if (process.platform === "win32")
32
+ return null;
33
+ try {
34
+ const output = child_process.execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
35
+ encoding: "utf8",
36
+ stdio: ["ignore", "pipe", "ignore"],
37
+ });
38
+ const startedAt = Date.parse(output.trim());
39
+ return Number.isFinite(startedAt) ? startedAt : null;
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ /** Cross-process, source-safe status for a currently running index operation. */
46
+ export function createIndexActivityReporter(repo) {
47
+ const target = activityPath(repo);
48
+ const startedAt = new Date().toISOString();
49
+ const write = (activity) => {
50
+ fs.mkdirSync(path.dirname(target), { recursive: true });
51
+ const temporary = `${target}.${process.pid}.tmp`;
52
+ try {
53
+ fs.writeFileSync(temporary, `${JSON.stringify(activity)}\n`, "utf8");
54
+ fs.renameSync(temporary, target);
55
+ }
56
+ finally {
57
+ fs.rmSync(temporary, { force: true });
58
+ }
59
+ };
60
+ return {
61
+ start() {
62
+ write({
63
+ pid: process.pid,
64
+ startedAt,
65
+ updatedAt: startedAt,
66
+ phase: "starting",
67
+ phaseCompleted: 0,
68
+ message: "Opening local graph database",
69
+ });
70
+ },
71
+ update(event) {
72
+ write({
73
+ pid: process.pid,
74
+ startedAt,
75
+ updatedAt: new Date().toISOString(),
76
+ phase: event.phase,
77
+ phaseCompleted: event.phaseCompleted,
78
+ ...(event.phaseTotal === undefined ? {} : { phaseTotal: event.phaseTotal }),
79
+ message: event.message,
80
+ });
81
+ },
82
+ stop() {
83
+ try {
84
+ const current = JSON.parse(fs.readFileSync(target, "utf8"));
85
+ if (current.pid === process.pid)
86
+ fs.unlinkSync(target);
87
+ }
88
+ catch { }
89
+ },
90
+ };
91
+ }
92
+ /** Return only a live operation; crash-left activity files never claim freshness. */
93
+ export function readIndexActivity(repo) {
94
+ try {
95
+ const activity = JSON.parse(fs.readFileSync(activityPath(repo), "utf8"));
96
+ if (!Number.isInteger(activity.pid) || activity.pid <= 0 || !processIsAlive(activity.pid))
97
+ return null;
98
+ const now = Date.now();
99
+ const startedAt = Date.parse(activity.startedAt);
100
+ const updatedAt = Date.parse(activity.updatedAt);
101
+ if (!Number.isFinite(startedAt) ||
102
+ !Number.isFinite(updatedAt) ||
103
+ startedAt > now + ACTIVITY_CLOCK_SKEW_MS ||
104
+ updatedAt < startedAt ||
105
+ updatedAt > now + ACTIVITY_CLOCK_SKEW_MS ||
106
+ now - startedAt > MAX_ACTIVITY_AGE_MS ||
107
+ !INDEX_PHASES.has(activity.phase) ||
108
+ !Number.isFinite(activity.phaseCompleted) ||
109
+ activity.phaseCompleted < 0 ||
110
+ (activity.phaseTotal !== undefined &&
111
+ (!Number.isFinite(activity.phaseTotal) ||
112
+ activity.phaseTotal < 0 ||
113
+ activity.phaseCompleted > activity.phaseTotal)) ||
114
+ typeof activity.message !== "string") {
115
+ return null;
116
+ }
117
+ const processStart = processStartedAt(activity.pid);
118
+ if (processStart !== null && processStart > startedAt + ACTIVITY_CLOCK_SKEW_MS)
119
+ return null;
120
+ return activity;
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ }
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+ import { clearLine as clearTerminalLine, moveCursor as moveTerminalCursor, cursorTo as moveTerminalCursorTo, } from "node:readline";
3
+ import ora from "ora";
4
+ import { formatProgress, normalizeTerminalDimension, } from "./init-progress.js";
5
+ const terminalEnabled = process.stderr.isTTY === true || process.env.RECKON_FORCE_PROGRESS_TTY === "1";
6
+ // Ora divides rendered text width by `stream.columns`. Some pseudo-terminals
7
+ // report zero columns, which turns its clear loop into an infinite loop. Keep
8
+ // the real terminal methods while guaranteeing finite viewport dimensions.
9
+ const progressStream = {
10
+ isTTY: terminalEnabled,
11
+ columns: normalizeTerminalDimension(process.stderr.columns, 80),
12
+ rows: normalizeTerminalDimension(process.stderr.rows, 24),
13
+ write: process.stderr.write.bind(process.stderr),
14
+ cursorTo: (x, y, callback) => moveTerminalCursorTo(process.stderr, x, y, callback),
15
+ moveCursor: (dx, dy, callback) => moveTerminalCursor(process.stderr, dx, dy, callback),
16
+ clearLine: (direction, callback) => clearTerminalLine(process.stderr, direction, callback),
17
+ once: process.stderr.once.bind(process.stderr),
18
+ removeListener: process.stderr.removeListener.bind(process.stderr),
19
+ };
20
+ const spinner = ora({
21
+ text: "[init:starting] Opening local graph database (0s elapsed)",
22
+ stream: progressStream,
23
+ isEnabled: terminalEnabled,
24
+ discardStdin: false,
25
+ });
26
+ let buffer = "";
27
+ let stopped = false;
28
+ let exiting = false;
29
+ let startedAt = 0;
30
+ let operation = "init";
31
+ let current = "[init:starting] Opening local graph database";
32
+ let clock;
33
+ function refreshText() {
34
+ const elapsed = Math.max(0, Math.floor((Date.now() - startedAt) / 1_000));
35
+ spinner.text = `${current} (${elapsed}s elapsed)`;
36
+ }
37
+ function start(nextOperation = operation) {
38
+ if (spinner.isSpinning)
39
+ return;
40
+ operation = nextOperation;
41
+ current = `[${operation}:starting] Opening local graph database`;
42
+ startedAt = Date.now();
43
+ refreshText();
44
+ spinner.start();
45
+ clock = setInterval(refreshText, 250);
46
+ }
47
+ function stop(exitAfter = false) {
48
+ if (!stopped) {
49
+ stopped = true;
50
+ if (clock)
51
+ clearInterval(clock);
52
+ spinner.stop();
53
+ }
54
+ if (exitAfter && !exiting) {
55
+ exiting = true;
56
+ const exitTimer = setTimeout(() => process.exit(0), 100);
57
+ process.stderr.write("\r\x1b[2K", () => {
58
+ clearTimeout(exitTimer);
59
+ process.exit(0);
60
+ });
61
+ }
62
+ }
63
+ function handle(line) {
64
+ if (!line)
65
+ return;
66
+ const message = JSON.parse(line);
67
+ if (message.type === "start")
68
+ start(message.operation);
69
+ else if (message.type === "progress") {
70
+ if (!spinner.isSpinning)
71
+ start();
72
+ current = formatProgress(message.event, undefined, operation);
73
+ refreshText();
74
+ }
75
+ else
76
+ stop(true);
77
+ }
78
+ process.stdin.setEncoding("utf8");
79
+ process.stdin.on("data", (chunk) => {
80
+ buffer += chunk;
81
+ for (;;) {
82
+ const newline = buffer.indexOf("\n");
83
+ if (newline < 0)
84
+ break;
85
+ const line = buffer.slice(0, newline);
86
+ buffer = buffer.slice(newline + 1);
87
+ try {
88
+ handle(line);
89
+ }
90
+ catch {
91
+ // Progress transport is observational; malformed input cannot affect init.
92
+ }
93
+ }
94
+ });
95
+ process.stdin.on("end", () => {
96
+ if (buffer) {
97
+ try {
98
+ handle(buffer);
99
+ }
100
+ catch { }
101
+ }
102
+ stop(true);
103
+ });
104
+ process.on("SIGTERM", () => {
105
+ stop();
106
+ process.exit(0);
107
+ });
@@ -0,0 +1,155 @@
1
+ const HEARTBEAT_MS = 15_000;
2
+ const TTY_THROTTLE_MS = 100;
3
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
4
+ const CLEAR_TTY_LINE = "\r\x1b[2K";
5
+ export function normalizeTerminalDimension(value, fallback) {
6
+ return Number.isFinite(value) && (value ?? 0) > 0 ? Math.floor(value) : fallback;
7
+ }
8
+ function formatCount(value) {
9
+ return value.toLocaleString();
10
+ }
11
+ function formatBytes(value) {
12
+ if (value < 1024 * 1024)
13
+ return `${Math.round(value / 1024)} KB`;
14
+ return `${(value / (1024 * 1024)).toFixed(1)} MB`;
15
+ }
16
+ function formatDuration(seconds) {
17
+ if (seconds < 1)
18
+ return "<1s";
19
+ if (seconds < 60)
20
+ return `${Math.ceil(seconds)}s`;
21
+ return `${Math.floor(seconds / 60)}m ${Math.ceil(seconds % 60)}s`;
22
+ }
23
+ export function formatProgress(event, ratePerSecond, operation = "init") {
24
+ const model = event.modelFile ? ` (${event.modelFile})` : "";
25
+ const total = event.phaseTotal;
26
+ if (total === undefined)
27
+ return `[${operation}:${event.phase}] ${event.message}${model}`;
28
+ const counter = event.phase === "embedding-model"
29
+ ? `${formatBytes(event.phaseCompleted)} / ${formatBytes(total)}`
30
+ : `${formatCount(event.phaseCompleted)} / ${formatCount(total)}`;
31
+ const percent = total > 0 ? ` (${Math.floor((event.phaseCompleted / total) * 100)}%)` : "";
32
+ const eta = ratePerSecond && total > event.phaseCompleted
33
+ ? ` • ~${formatDuration((total - event.phaseCompleted) / ratePerSecond)} remaining`
34
+ : "";
35
+ const rate = ratePerSecond && event.phase !== "embedding-model"
36
+ ? ` • ${ratePerSecond.toFixed(ratePerSecond >= 10 ? 0 : 1)}/s`
37
+ : "";
38
+ return `[${operation}:${event.phase}] ${counter}${percent} ${event.message}${model}${rate}${eta}`;
39
+ }
40
+ /**
41
+ * Human-only init feedback. It writes to stderr, leaving stdout stable for the
42
+ * final result, redraws a TTY at most 10 times/sec, and heartbeats a quiet phase.
43
+ */
44
+ export function createInitProgressRenderer(options) {
45
+ const operation = options.operation ?? "init";
46
+ const now = options.now ?? Date.now;
47
+ const schedule = options.setTimeout ??
48
+ ((callback, intervalMs) => setTimeout(callback, intervalMs));
49
+ const unschedule = options.clearTimeout ?? ((handle) => clearTimeout(handle));
50
+ const tty = options.stderr.isTTY === true;
51
+ let started = false;
52
+ let stopped = false;
53
+ let hasTtyLine = false;
54
+ let startedAt = 0;
55
+ let heartbeat;
56
+ let spinner;
57
+ let spinnerFrame = 0;
58
+ let displayLine = `[${operation}:starting] Opening local graph database`;
59
+ let lastWriteAt = Number.NEGATIVE_INFINITY;
60
+ let lastPhase = "starting";
61
+ let lastPercentBucket = -1;
62
+ let phaseStartedElapsedMs = 0;
63
+ let phaseStartedCompleted = 0;
64
+ const scheduleHeartbeat = () => {
65
+ if (heartbeat !== undefined || stopped)
66
+ return;
67
+ heartbeat = schedule(() => {
68
+ heartbeat = undefined;
69
+ if (stopped)
70
+ return;
71
+ write(`${displayLine} (still working; ${Math.floor((now() - startedAt) / 1000)}s elapsed)`, false);
72
+ scheduleHeartbeat();
73
+ }, HEARTBEAT_MS);
74
+ if (typeof heartbeat === "object" && heartbeat !== null && "unref" in heartbeat) {
75
+ heartbeat.unref();
76
+ }
77
+ };
78
+ const scheduleSpinner = () => {
79
+ if (!tty || spinner !== undefined || stopped)
80
+ return;
81
+ spinner = schedule(() => {
82
+ spinner = undefined;
83
+ if (stopped)
84
+ return;
85
+ spinnerFrame = (spinnerFrame + 1) % SPINNER_FRAMES.length;
86
+ options.stderr.write(`${CLEAR_TTY_LINE}${SPINNER_FRAMES[spinnerFrame]} ${displayLine} (${Math.floor((now() - startedAt) / 1000)}s elapsed)`);
87
+ hasTtyLine = true;
88
+ scheduleSpinner();
89
+ }, TTY_THROTTLE_MS);
90
+ if (typeof spinner === "object" && spinner !== null && "unref" in spinner) {
91
+ spinner.unref();
92
+ }
93
+ };
94
+ const write = (line, updateDisplay = true) => {
95
+ if (updateDisplay)
96
+ displayLine = line;
97
+ if (tty) {
98
+ options.stderr.write(`${CLEAR_TTY_LINE}${SPINNER_FRAMES[spinnerFrame]} ${line} (${Math.floor((now() - startedAt) / 1000)}s elapsed)`);
99
+ hasTtyLine = true;
100
+ }
101
+ else {
102
+ options.stderr.write(`${line}\n`);
103
+ }
104
+ lastWriteAt = now();
105
+ scheduleHeartbeat();
106
+ };
107
+ return {
108
+ start() {
109
+ if (started || stopped)
110
+ return;
111
+ started = true;
112
+ startedAt = now();
113
+ scheduleSpinner();
114
+ write(displayLine);
115
+ },
116
+ onProgress(event) {
117
+ if (stopped)
118
+ return;
119
+ if (!started)
120
+ this.start();
121
+ const percentage = event.phaseTotal && event.phaseTotal > 0
122
+ ? Math.floor((event.phaseCompleted / event.phaseTotal) * 100)
123
+ : undefined;
124
+ const bucket = percentage === undefined ? -1 : Math.floor(percentage / 5);
125
+ const phaseChanged = event.phase !== lastPhase;
126
+ if (phaseChanged) {
127
+ phaseStartedElapsedMs = event.elapsedMs;
128
+ phaseStartedCompleted = event.phaseCompleted;
129
+ }
130
+ const phaseElapsedSeconds = Math.max(0, event.elapsedMs - phaseStartedElapsedMs) / 1_000;
131
+ const ratePerSecond = phaseElapsedSeconds > 0 && event.phaseCompleted > phaseStartedCompleted
132
+ ? (event.phaseCompleted - phaseStartedCompleted) / phaseElapsedSeconds
133
+ : undefined;
134
+ const completed = event.phaseTotal !== undefined && event.phaseCompleted >= event.phaseTotal;
135
+ if (tty && !phaseChanged && !completed && now() - lastWriteAt < TTY_THROTTLE_MS)
136
+ return;
137
+ if (!tty && !phaseChanged && bucket <= lastPercentBucket && !completed)
138
+ return;
139
+ write(formatProgress(event, ratePerSecond, operation));
140
+ lastPhase = event.phase;
141
+ lastPercentBucket = Math.max(lastPercentBucket, bucket);
142
+ },
143
+ stop() {
144
+ if (stopped)
145
+ return;
146
+ stopped = true;
147
+ if (heartbeat !== undefined)
148
+ unschedule(heartbeat);
149
+ if (spinner !== undefined)
150
+ unschedule(spinner);
151
+ if (tty && hasTtyLine)
152
+ options.stderr.write(`${CLEAR_TTY_LINE}\n`);
153
+ },
154
+ };
155
+ }