@stupify/cli 0.0.14 → 0.0.15

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stupify contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Local-only diagnostic CLI for checking whether AI is making you dumber.
4
4
 
5
+ Released under the MIT License.
6
+
5
7
  Stupify has one analysis path:
6
8
 
7
9
  ```text
@@ -48,7 +50,8 @@ stupify --staged --max-search-input-tokens 24000
48
50
  ```
49
51
 
50
52
  The package is prepared for the public `@stupify` npm scope. Publishing should
51
- run the TypeScript build first so the executable points at `dist/stupify.js`.
53
+ use the repository release workflow so npm receives Trusted Publishing
54
+ provenance. See the repository release docs.
52
55
 
53
56
  This iteration intentionally does not run findings audit, validators, judges,
54
57
  baselines, hosted LLM APIs, GitHub integration, dashboards, or repo-wide
@@ -1,4 +1,4 @@
1
- export declare const VERSION = "0.0.14";
1
+ export declare const VERSION = "0.0.15";
2
2
  import type { ModelConfig, ModelId } from "./types.ts";
3
3
  export declare const DEFAULT_MODEL_ID: ModelId;
4
4
  export declare const MODEL_REGISTRY: Record<ModelId, ModelConfig>;
package/dist/constants.js CHANGED
@@ -1,4 +1,4 @@
1
- export const VERSION = "0.0.14";
1
+ export const VERSION = "0.0.15";
2
2
  export const DEFAULT_MODEL_ID = "gemma-4-e2b";
3
3
  export const MODEL_REGISTRY = {
4
4
  "gemma-4-e2b": {
package/dist/stupify.js CHANGED
@@ -48,12 +48,32 @@ export async function main(argv = process.argv.slice(2)) {
48
48
  }
49
49
  }
50
50
  export async function runSearchCommand(command, startedAt, ui = createCliUi({ quiet: command.json })) {
51
+ const activeSpans = new Map();
51
52
  const t = createTracer({
52
53
  writeLine: () => undefined,
53
54
  onEvent: (event) => {
54
55
  if (command.json)
55
56
  return;
56
- ui.step(formatStep(event.name, event.ms, event.count, event.detail));
57
+ if (event.phase === "start") {
58
+ activeSpans.set(event.name, ui.spinner(formatStartStep(event.name, event.detail)));
59
+ return;
60
+ }
61
+ const active = activeSpans.get(event.name);
62
+ activeSpans.delete(event.name);
63
+ const message = event.phase === "error"
64
+ ? formatErrorStep(event.name, event.ms)
65
+ : formatStep(event.name, event.ms, event.count, event.detail);
66
+ if (!active) {
67
+ if (event.phase === "error")
68
+ ui.error(message);
69
+ else
70
+ ui.step(message);
71
+ return;
72
+ }
73
+ if (event.phase === "error")
74
+ active.error(message);
75
+ else
76
+ active.stop(message);
57
77
  },
58
78
  });
59
79
  const profile = await loadSearchProfile(command.searchProfilePath);
@@ -143,7 +163,7 @@ export async function runSearchCommand(command, startedAt, ui = createCliUi({ qu
143
163
  const pack = profile?.context === "sem" || searchContexts.length === contexts.length
144
164
  ? initialPack
145
165
  : await repomixContextPack(changeSet.contextCwd, searchContexts, changeSet.changes, baseRepomixConfig);
146
- const batches = await buildSearchBatches({
166
+ const { value: batches } = await t.trace("search.batches", () => buildSearchBatches({
147
167
  command,
148
168
  changeSet,
149
169
  contexts: searchContexts,
@@ -153,6 +173,12 @@ export async function runSearchCommand(command, startedAt, ui = createCliUi({ qu
153
173
  includeCounterReasonInPrompt: command.includeCounterReasonInPrompt,
154
174
  maxSearchInputTokens,
155
175
  baseRepomixConfig,
176
+ }), {
177
+ startDetail: `${searchContexts.length} targets`,
178
+ count: (result) => result.batches.length,
179
+ detail: (result) => result.wasSplit
180
+ ? `${result.skippedTargets} oversized targets skipped`
181
+ : `${result.estimatedInputTokens} estimated tokens`,
156
182
  });
157
183
  if (batches.batches.length === 0) {
158
184
  return {
@@ -201,7 +227,10 @@ export async function runSearchCommand(command, startedAt, ui = createCliUi({ qu
201
227
  let inputTokens = 0;
202
228
  let exactSkippedTargets = batches.skippedTargets;
203
229
  for (const batch of batches.batches) {
204
- const batchInputTokens = await countPromptTokens(model, batch.request.prompt);
230
+ const { value: batchInputTokens } = await t.trace("prompt.tokens", () => countPromptTokens(model, batch.request.prompt), {
231
+ startDetail: `${batch.contexts.length} targets`,
232
+ count: (tokens) => tokens,
233
+ });
205
234
  inputTokens += batchInputTokens;
206
235
  if (batchInputTokens > maxSearchInputTokens) {
207
236
  exactSkippedTargets += batch.contexts.length;
@@ -210,7 +239,10 @@ export async function runSearchCommand(command, startedAt, ui = createCliUi({ qu
210
239
  }
211
240
  continue;
212
241
  }
213
- const { value } = await t.trace("search.model", () => runSearch(model, batch.request), { count: (v) => v.length });
242
+ const { value } = await t.trace("search.model", () => runSearch(model, batch.request), {
243
+ startDetail: `${batch.contexts.length} targets`,
244
+ count: (v) => v.length,
245
+ });
214
246
  modelCalls += 1;
215
247
  matches.push(...withCheckWhy(value, checks));
216
248
  }
@@ -344,15 +376,45 @@ function printRunPlan(command, patternIds, ui) {
344
376
  `Patterns: ${patternIds.join(", ")}`,
345
377
  ].join("\n"), "Run");
346
378
  }
379
+ function formatStartStep(name, detail) {
380
+ if (name === "entity.diff")
381
+ return "Diff: running sem over the selected git range";
382
+ if (name === "context.pack")
383
+ return "Context: packing selected target files with Repomix";
384
+ if (name === "search.batches")
385
+ return `Search: preparing token-bounded model batches${detail ? ` for ${detail}` : ""}`;
386
+ if (name === "prompt.tokens")
387
+ return `Tokens: counting search prompt${detail ? ` for ${detail}` : ""}`;
388
+ if (name === "search.model")
389
+ return `Model: searching selected target/check pairs${detail ? ` (${detail})` : ""}`;
390
+ return `${name}: working`;
391
+ }
347
392
  function formatStep(name, ms, count, detail) {
348
393
  if (name === "entity.diff")
349
394
  return `Diff: ${detail ?? "changed files"}, ${count ?? 0} changed entities (${ms}ms)`;
350
395
  if (name === "context.pack")
351
396
  return `Context: ${count ?? 0} files, ${detail ?? "0 tokens"} (${ms}ms)`;
397
+ if (name === "search.batches")
398
+ return `Search: ${count ?? 0} model batches, ${detail ?? "0 estimated tokens"} (${ms}ms)`;
399
+ if (name === "prompt.tokens")
400
+ return `Tokens: ${count ?? 0} prompt tokens (${ms}ms)`;
352
401
  if (name === "search.model")
353
402
  return `Model: ${count ?? 0} matches (${ms}ms)`;
354
403
  return `${name}: ${ms}ms`;
355
404
  }
405
+ function formatErrorStep(name, ms) {
406
+ if (name === "entity.diff")
407
+ return `Diff failed after ${ms}ms`;
408
+ if (name === "context.pack")
409
+ return `Context packing failed after ${ms}ms`;
410
+ if (name === "search.batches")
411
+ return `Search batch preparation failed after ${ms}ms`;
412
+ if (name === "prompt.tokens")
413
+ return `Token counting failed after ${ms}ms`;
414
+ if (name === "search.model")
415
+ return `Model search failed after ${ms}ms`;
416
+ return `${name} failed after ${ms}ms`;
417
+ }
356
418
  function scoutPlanLine(plan, entitiesScanned) {
357
419
  if (plan.targets.length === 0) {
358
420
  return `Scout: deterministic counters scanned ${entitiesScanned} entities; no target/check pairs selected`;
package/dist/trace.d.ts CHANGED
@@ -11,12 +11,14 @@ export type Tracer = {
11
11
  };
12
12
  export type SpanTraceEvent = Readonly<{
13
13
  name: string;
14
+ phase: "start" | "end" | "error";
14
15
  ms: number;
15
16
  count?: number;
16
17
  detail?: string;
17
18
  }>;
18
19
  export type SpanTraceOptions<T> = Readonly<{
19
20
  fields?: TraceFields;
21
+ startDetail?: string | (() => string);
20
22
  count?: (value: T) => number;
21
23
  detail?: (value: T) => string;
22
24
  }>;
package/dist/trace.js CHANGED
@@ -16,6 +16,12 @@ export function createTracer(options) {
16
16
  }
17
17
  function trace(span, fn, options) {
18
18
  const startedAtMs = nowMs();
19
+ onEvent?.({
20
+ name: span,
21
+ phase: "start",
22
+ ms: 0,
23
+ detail: typeof options?.startDetail === "function" ? options.startDetail() : options?.startDetail,
24
+ });
19
25
  try {
20
26
  const out = fn();
21
27
  if (isPromiseLike(out)) {
@@ -26,6 +32,7 @@ export function createTracer(options) {
26
32
  durationMs = nowMs() - startedAtMs;
27
33
  const event = {
28
34
  name: span,
35
+ phase: "end",
29
36
  ms: Math.round(durationMs),
30
37
  count: options?.count?.(value),
31
38
  detail: options?.detail?.(value),
@@ -33,6 +40,15 @@ export function createTracer(options) {
33
40
  onEvent?.(event);
34
41
  return { value, ms: event.ms };
35
42
  }
43
+ catch (error) {
44
+ durationMs = nowMs() - startedAtMs;
45
+ onEvent?.({
46
+ name: span,
47
+ phase: "error",
48
+ ms: Math.round(durationMs),
49
+ });
50
+ throw error;
51
+ }
36
52
  finally {
37
53
  durationMs ??= nowMs() - startedAtMs;
38
54
  emit(span, durationMs, options?.fields);
@@ -43,6 +59,7 @@ export function createTracer(options) {
43
59
  emit(span, durationMs, options?.fields);
44
60
  const event = {
45
61
  name: span,
62
+ phase: "end",
46
63
  ms: Math.round(durationMs),
47
64
  count: options?.count?.(out),
48
65
  detail: options?.detail?.(out),
@@ -52,6 +69,11 @@ export function createTracer(options) {
52
69
  }
53
70
  catch (error) {
54
71
  const durationMs = nowMs() - startedAtMs;
72
+ onEvent?.({
73
+ name: span,
74
+ phase: "error",
75
+ ms: Math.round(durationMs),
76
+ });
55
77
  emit(span, durationMs, options?.fields);
56
78
  throw error;
57
79
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stupify/cli",
3
- "version": "0.0.14",
3
+ "version": "0.0.15",
4
4
  "description": "Local-only diagnostic CLI for checking whether AI is making you dumber.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "repository": {
11
11
  "type": "git",
12
- "url": "git+ssh://git@github.com/Octember/stupif.ai.git",
12
+ "url": "git+https://github.com/Octember/stupif.ai.git",
13
13
  "directory": "packages/cli"
14
14
  },
15
15
  "homepage": "https://stupif.ai",
@@ -23,16 +23,18 @@
23
23
  "local-first",
24
24
  "code-review"
25
25
  ],
26
- "license": "UNLICENSED",
26
+ "license": "MIT",
27
27
  "engines": {
28
28
  "node": ">=20"
29
29
  },
30
30
  "publishConfig": {
31
- "access": "public"
31
+ "access": "public",
32
+ "provenance": true
32
33
  },
33
34
  "files": [
34
35
  "dist",
35
36
  "src",
37
+ "LICENSE",
36
38
  "README.md",
37
39
  "package.json"
38
40
  ],
package/src/constants.ts CHANGED
@@ -1,4 +1,4 @@
1
- export const VERSION = "0.0.14";
1
+ export const VERSION = "0.0.15";
2
2
  import type { ModelConfig, ModelId } from "./types.ts";
3
3
 
4
4
  export const DEFAULT_MODEL_ID: ModelId = "gemma-4-e2b";
package/src/stupify.ts CHANGED
@@ -59,11 +59,28 @@ export async function main(argv = process.argv.slice(2)): Promise<number> {
59
59
  }
60
60
 
61
61
  export async function runSearchCommand(command: SearchCommand, startedAt: number, ui = createCliUi({ quiet: command.json })): Promise<SearchRunJson> {
62
+ const activeSpans = new Map<string, ReturnType<CliUi["spinner"]>>();
62
63
  const t = createTracer({
63
64
  writeLine: () => undefined,
64
65
  onEvent: (event) => {
65
66
  if (command.json) return;
66
- ui.step(formatStep(event.name, event.ms, event.count, event.detail));
67
+ if (event.phase === "start") {
68
+ activeSpans.set(event.name, ui.spinner(formatStartStep(event.name, event.detail)));
69
+ return;
70
+ }
71
+
72
+ const active = activeSpans.get(event.name);
73
+ activeSpans.delete(event.name);
74
+ const message = event.phase === "error"
75
+ ? formatErrorStep(event.name, event.ms)
76
+ : formatStep(event.name, event.ms, event.count, event.detail);
77
+ if (!active) {
78
+ if (event.phase === "error") ui.error(message);
79
+ else ui.step(message);
80
+ return;
81
+ }
82
+ if (event.phase === "error") active.error(message);
83
+ else active.stop(message);
67
84
  },
68
85
  });
69
86
 
@@ -162,17 +179,27 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
162
179
  const pack = profile?.context === "sem" || searchContexts.length === contexts.length
163
180
  ? initialPack
164
181
  : await repomixContextPack(changeSet.contextCwd, searchContexts, changeSet.changes, baseRepomixConfig);
165
- const batches = await buildSearchBatches({
166
- command,
167
- changeSet,
168
- contexts: searchContexts,
169
- initialPack: pack,
170
- checks,
171
- profile,
172
- includeCounterReasonInPrompt: command.includeCounterReasonInPrompt,
173
- maxSearchInputTokens,
174
- baseRepomixConfig,
175
- });
182
+ const { value: batches } = await t.trace(
183
+ "search.batches",
184
+ () => buildSearchBatches({
185
+ command,
186
+ changeSet,
187
+ contexts: searchContexts,
188
+ initialPack: pack,
189
+ checks,
190
+ profile,
191
+ includeCounterReasonInPrompt: command.includeCounterReasonInPrompt,
192
+ maxSearchInputTokens,
193
+ baseRepomixConfig,
194
+ }),
195
+ {
196
+ startDetail: `${searchContexts.length} targets`,
197
+ count: (result) => result.batches.length,
198
+ detail: (result) => result.wasSplit
199
+ ? `${result.skippedTargets} oversized targets skipped`
200
+ : `${result.estimatedInputTokens} estimated tokens`,
201
+ },
202
+ );
176
203
 
177
204
  if (batches.batches.length === 0) {
178
205
  return {
@@ -222,7 +249,14 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
222
249
  let inputTokens = 0;
223
250
  let exactSkippedTargets = batches.skippedTargets;
224
251
  for (const batch of batches.batches) {
225
- const batchInputTokens = await countPromptTokens(model, batch.request.prompt);
252
+ const { value: batchInputTokens } = await t.trace(
253
+ "prompt.tokens",
254
+ () => countPromptTokens(model, batch.request.prompt),
255
+ {
256
+ startDetail: `${batch.contexts.length} targets`,
257
+ count: (tokens) => tokens,
258
+ },
259
+ );
226
260
  inputTokens += batchInputTokens;
227
261
  if (batchInputTokens > maxSearchInputTokens) {
228
262
  exactSkippedTargets += batch.contexts.length;
@@ -234,7 +268,10 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
234
268
  const { value } = await t.trace(
235
269
  "search.model",
236
270
  () => runSearch(model, batch.request),
237
- { count: (v) => v.length },
271
+ {
272
+ startDetail: `${batch.contexts.length} targets`,
273
+ count: (v) => v.length,
274
+ },
238
275
  );
239
276
  modelCalls += 1;
240
277
  matches.push(...withCheckWhy(value, checks));
@@ -441,13 +478,33 @@ function printRunPlan(
441
478
  );
442
479
  }
443
480
 
481
+ function formatStartStep(name: string, detail?: string): string {
482
+ if (name === "entity.diff") return "Diff: running sem over the selected git range";
483
+ if (name === "context.pack") return "Context: packing selected target files with Repomix";
484
+ if (name === "search.batches") return `Search: preparing token-bounded model batches${detail ? ` for ${detail}` : ""}`;
485
+ if (name === "prompt.tokens") return `Tokens: counting search prompt${detail ? ` for ${detail}` : ""}`;
486
+ if (name === "search.model") return `Model: searching selected target/check pairs${detail ? ` (${detail})` : ""}`;
487
+ return `${name}: working`;
488
+ }
489
+
444
490
  function formatStep(name: string, ms: number, count?: number, detail?: string): string {
445
491
  if (name === "entity.diff") return `Diff: ${detail ?? "changed files"}, ${count ?? 0} changed entities (${ms}ms)`;
446
492
  if (name === "context.pack") return `Context: ${count ?? 0} files, ${detail ?? "0 tokens"} (${ms}ms)`;
493
+ if (name === "search.batches") return `Search: ${count ?? 0} model batches, ${detail ?? "0 estimated tokens"} (${ms}ms)`;
494
+ if (name === "prompt.tokens") return `Tokens: ${count ?? 0} prompt tokens (${ms}ms)`;
447
495
  if (name === "search.model") return `Model: ${count ?? 0} matches (${ms}ms)`;
448
496
  return `${name}: ${ms}ms`;
449
497
  }
450
498
 
499
+ function formatErrorStep(name: string, ms: number): string {
500
+ if (name === "entity.diff") return `Diff failed after ${ms}ms`;
501
+ if (name === "context.pack") return `Context packing failed after ${ms}ms`;
502
+ if (name === "search.batches") return `Search batch preparation failed after ${ms}ms`;
503
+ if (name === "prompt.tokens") return `Token counting failed after ${ms}ms`;
504
+ if (name === "search.model") return `Model search failed after ${ms}ms`;
505
+ return `${name} failed after ${ms}ms`;
506
+ }
507
+
451
508
  function scoutPlanLine(plan: CounterScoutPlan, entitiesScanned: number): string {
452
509
  if (plan.targets.length === 0) {
453
510
  return `Scout: deterministic counters scanned ${entitiesScanned} entities; no target/check pairs selected`;
package/src/trace.ts CHANGED
@@ -9,6 +9,7 @@ export type Tracer = {
9
9
 
10
10
  export type SpanTraceEvent = Readonly<{
11
11
  name: string;
12
+ phase: "start" | "end" | "error";
12
13
  ms: number;
13
14
  count?: number;
14
15
  detail?: string;
@@ -16,6 +17,7 @@ export type SpanTraceEvent = Readonly<{
16
17
 
17
18
  export type SpanTraceOptions<T> = Readonly<{
18
19
  fields?: TraceFields;
20
+ startDetail?: string | (() => string);
19
21
  count?: (value: T) => number;
20
22
  detail?: (value: T) => string;
21
23
  }>;
@@ -53,6 +55,12 @@ export function createTracer(options?: CreateTracerOptions): Tracer {
53
55
  options?: SpanTraceOptions<T>,
54
56
  ): Promise<{ value: T; ms: number }> | { value: T; ms: number } {
55
57
  const startedAtMs = nowMs();
58
+ onEvent?.({
59
+ name: span,
60
+ phase: "start",
61
+ ms: 0,
62
+ detail: typeof options?.startDetail === "function" ? options.startDetail() : options?.startDetail,
63
+ });
56
64
  try {
57
65
  const out = fn();
58
66
  if (isPromiseLike(out)) {
@@ -63,12 +71,21 @@ export function createTracer(options?: CreateTracerOptions): Tracer {
63
71
  durationMs = nowMs() - startedAtMs;
64
72
  const event: SpanTraceEvent = {
65
73
  name: span,
74
+ phase: "end",
66
75
  ms: Math.round(durationMs),
67
76
  count: options?.count?.(value),
68
77
  detail: options?.detail?.(value),
69
78
  };
70
79
  onEvent?.(event);
71
80
  return { value, ms: event.ms };
81
+ } catch (error) {
82
+ durationMs = nowMs() - startedAtMs;
83
+ onEvent?.({
84
+ name: span,
85
+ phase: "error",
86
+ ms: Math.round(durationMs),
87
+ });
88
+ throw error;
72
89
  } finally {
73
90
  durationMs ??= nowMs() - startedAtMs;
74
91
  emit(span, durationMs, options?.fields);
@@ -80,6 +97,7 @@ export function createTracer(options?: CreateTracerOptions): Tracer {
80
97
  emit(span, durationMs, options?.fields);
81
98
  const event: SpanTraceEvent = {
82
99
  name: span,
100
+ phase: "end",
83
101
  ms: Math.round(durationMs),
84
102
  count: options?.count?.(out),
85
103
  detail: options?.detail?.(out),
@@ -88,6 +106,11 @@ export function createTracer(options?: CreateTracerOptions): Tracer {
88
106
  return { value: out, ms: event.ms };
89
107
  } catch (error) {
90
108
  const durationMs = nowMs() - startedAtMs;
109
+ onEvent?.({
110
+ name: span,
111
+ phase: "error",
112
+ ms: Math.round(durationMs),
113
+ });
91
114
  emit(span, durationMs, options?.fields);
92
115
  throw error;
93
116
  }