sprag-cli 3.40.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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +637 -0
  3. package/README.md +758 -0
  4. package/bin/cli.js +801 -0
  5. package/examples/statusline-command.ps1 +43 -0
  6. package/examples/statusline-command.sh +36 -0
  7. package/package.json +62 -0
  8. package/presets/cohesion/cohesion-en.md +26 -0
  9. package/presets/doc2md/convert.py +363 -0
  10. package/presets/korean-style/LICENSE-fluent-korean +21 -0
  11. package/presets/korean-style/fluent-korean.md +52 -0
  12. package/presets/korean-style/supplement.md +93 -0
  13. package/presets/model-rules.json +115 -0
  14. package/presets/ratchet-rules.json +38 -0
  15. package/src/advice.js +564 -0
  16. package/src/agents.js +52 -0
  17. package/src/brief.js +264 -0
  18. package/src/caps-cache.js +84 -0
  19. package/src/cli-args.js +51 -0
  20. package/src/cohesion.js +70 -0
  21. package/src/commands/brief.js +31 -0
  22. package/src/commands/cohesion.js +59 -0
  23. package/src/commands/compact-window.js +93 -0
  24. package/src/commands/doc2md.js +166 -0
  25. package/src/commands/feedback.js +132 -0
  26. package/src/commands/handoff.js +33 -0
  27. package/src/commands/harness.js +459 -0
  28. package/src/commands/history.js +46 -0
  29. package/src/commands/install.js +358 -0
  30. package/src/commands/korean.js +220 -0
  31. package/src/commands/last.js +151 -0
  32. package/src/commands/mode.js +46 -0
  33. package/src/commands/route-scan.js +454 -0
  34. package/src/commands/seed.js +105 -0
  35. package/src/commands/uninstall.js +42 -0
  36. package/src/commands/update-check.js +77 -0
  37. package/src/commands/upgrade.js +68 -0
  38. package/src/compact-window.js +205 -0
  39. package/src/config.js +232 -0
  40. package/src/cost.js +253 -0
  41. package/src/debug.js +29 -0
  42. package/src/demo.js +331 -0
  43. package/src/doc2md-ledger.cjs +227 -0
  44. package/src/doc2md.cjs +997 -0
  45. package/src/fig2md-runner.cjs +21 -0
  46. package/src/fig2md.cjs +191 -0
  47. package/src/first-run-note.js +63 -0
  48. package/src/format-time.js +44 -0
  49. package/src/formatters/csv.js +8 -0
  50. package/src/formatters/json.js +3 -0
  51. package/src/formatters/statusline.js +750 -0
  52. package/src/formatters/table.js +299 -0
  53. package/src/handoff.js +161 -0
  54. package/src/harness-analyzer.cjs +264 -0
  55. package/src/harness-templates.js +153 -0
  56. package/src/harness.js +613 -0
  57. package/src/history.js +383 -0
  58. package/src/hook-manager.js +96 -0
  59. package/src/hook.cjs +196 -0
  60. package/src/installer.js +614 -0
  61. package/src/korean-lint.cjs +303 -0
  62. package/src/korean-style.js +187 -0
  63. package/src/litellm-budget.js +223 -0
  64. package/src/model-alias.js +484 -0
  65. package/src/model-rules.js +527 -0
  66. package/src/month-spend.js +47 -0
  67. package/src/parser.js +330 -0
  68. package/src/paths.js +41 -0
  69. package/src/prompt.js +52 -0
  70. package/src/route-scan.js +832 -0
  71. package/src/savings-ledger.js +137 -0
  72. package/src/seed-rules.js +280 -0
  73. package/src/session-cache.js +160 -0
  74. package/src/session-records.js +188 -0
  75. package/src/stats.js +380 -0
  76. package/src/stdin-payload.js +122 -0
  77. package/src/subagent-records.js +214 -0
  78. package/src/update-check.js +201 -0
  79. package/src/window-labels.js +64 -0
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Child-process shim between the synchronous doc2md pipeline and the async
4
+ * fig converter. Prints the conversion result as one JSON object on stdout,
5
+ * exactly like the Python converter does, so the caller treats both the same.
6
+ *
7
+ * Usage: node fig2md-runner.cjs <file.fig> <userDataDir>
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const { convertFig } = require('./fig2md.cjs');
13
+
14
+ const [file, userDataDir] = process.argv.slice(2);
15
+ convertFig(file, userDataDir)
16
+ .then((result) => { process.stdout.write(JSON.stringify(result)); })
17
+ .catch((e) => {
18
+ process.stdout.write(JSON.stringify({
19
+ ok: false, reason: 'convert-failed', detail: String(e && e.message || e).slice(0, 300),
20
+ }));
21
+ });
package/src/fig2md.cjs ADDED
@@ -0,0 +1,191 @@
1
+ /**
2
+ * fig2md β€” Markdown rendering of a Figma `.fig` export.
3
+ *
4
+ * Planning documents increasingly live in Figma rather than PowerPoint, and a
5
+ * `.fig` handed to the model is even more opaque than a pptx: the container
6
+ * is a zip, but the payload inside (`canvas.fig`) is Figma's binary kiwi
7
+ * format, so there is no XML to fall back on. Without a converter the file is
8
+ * simply unreadable.
9
+ *
10
+ * markitdown does not speak this format, so the conversion runs in Node with
11
+ * [openfig-core] (MIT, three small pure-JS dependencies). The parser is not
12
+ * bundled: this package deliberately ships with zero dependencies, so
13
+ * openfig-core is installed on demand into the tool's own state directory β€”
14
+ * the same arrangement as the markitdown venv, for the same reason.
15
+ *
16
+ * Verified 2026-09-06 against real files: a community Bootstrap UI kit
17
+ * (8.1MB, 4,155 nodes, 1,312 of them text) and a 52MB Tailwind kit, each
18
+ * converting in under a second, plus a round-trip fixture whose Korean text
19
+ * nodes came back byte-identical. Both .fig vintages parse β€” the current
20
+ * zip container and the older bare fig-kiwi stream.
21
+ *
22
+ * [openfig-core]: https://github.com/OpenFig-org/openfig-core
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const fs = require('node:fs');
28
+ const path = require('node:path');
29
+ const { spawnSync } = require('node:child_process');
30
+
31
+ // `0.4.x` rather than `^0.4.1` on purpose: the Windows install goes through
32
+ // cmd.exe, where `^` is the escape character and would be eaten before npm
33
+ // ever saw it. The range is the same one caret means for a 0.x package.
34
+ const FIG_PARSER_SPEC = 'openfig-core@0.4.x';
35
+
36
+ /** Where the on-demand parser install lives, under the tool's state dir. */
37
+ function managedFigDir(userDataDir) {
38
+ return path.join(userDataDir, 'doc2md-fig');
39
+ }
40
+
41
+ function loadParser(userDataDir) {
42
+ try {
43
+ // eslint-disable-next-line import/no-dynamic-require
44
+ return require(path.join(managedFigDir(userDataDir), 'node_modules', 'openfig-core', 'dist', 'index.cjs'));
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Install openfig-core into the managed directory. Mirrors the markitdown
52
+ * venv install: network once, probe as the acceptance test.
53
+ */
54
+ function installFigParser(userDataDir, { onProgress = () => {} } = {}) {
55
+ const dir = managedFigDir(userDataDir);
56
+ fs.mkdirSync(dir, { recursive: true });
57
+ const pkgJson = path.join(dir, 'package.json');
58
+ if (!fs.existsSync(pkgJson)) {
59
+ fs.writeFileSync(pkgJson, JSON.stringify({ name: 'doc2md-fig', private: true }) + '\n');
60
+ }
61
+ onProgress(`installing ${FIG_PARSER_SPEC}`);
62
+ // npm on Windows is npm.cmd, which spawnSync cannot execute directly β€” it
63
+ // needs the shell. Elsewhere the shell is avoided, since the package spec
64
+ // would then go through shell quoting for no benefit.
65
+ const isWindows = process.platform === 'win32';
66
+ const r = spawnSync(isWindows ? 'npm.cmd' : 'npm', ['install', '--no-audit', '--no-fund', '--silent', FIG_PARSER_SPEC], {
67
+ cwd: dir,
68
+ encoding: 'utf8',
69
+ timeout: 300_000,
70
+ windowsHide: true,
71
+ shell: isWindows,
72
+ });
73
+ if (r.status !== 0) {
74
+ return { ok: false, reason: 'npm-failed', detail: (r.stderr || '').slice(0, 400) };
75
+ }
76
+ if (!loadParser(userDataDir)) {
77
+ return { ok: false, reason: 'import-failed', detail: 'installed, but openfig-core does not load' };
78
+ }
79
+ return { ok: true, dir };
80
+ }
81
+
82
+ /**
83
+ * Node name plus type, indented by depth: the skeleton lines of the outline.
84
+ */
85
+ function heading(node, depth) {
86
+ const name = String(node.name || '').trim() || '(이름 μ—†μŒ)';
87
+ return `${'#'.repeat(Math.min(depth + 2, 6))} ${name}`;
88
+ }
89
+
90
+ /**
91
+ * Walk the parsed document and render an outline.
92
+ *
93
+ * The traversal follows `childrenMap` (guid β†’ ordered children), which is how
94
+ * the parser exposes hierarchy. Containers become headings, TEXT nodes become
95
+ * body lines, and everything visual (vectors, rectangles, images) is counted
96
+ * rather than listed: in a planning document the words are the content, and
97
+ * two hundred `Rectangle 173` lines would drown them.
98
+ */
99
+ function renderMarkdown(doc, sourceName) {
100
+ const guidKey = (g) => `${g.sessionID}:${g.localID}`;
101
+ const lines = [];
102
+ const skipped = Object.create(null);
103
+ let textNodes = 0;
104
+
105
+ const CONTAINERS = new Set(['DOCUMENT', 'CANVAS', 'FRAME', 'GROUP', 'SECTION', 'COMPONENT', 'COMPONENT_SET', 'INSTANCE', 'SLIDE', 'SYMBOL']);
106
+
107
+ function walk(node, depth) {
108
+ if (!node || node.phase === 'REMOVED' || node.visible === false) return;
109
+ const type = node.type || '?';
110
+ if (type === 'TEXT') {
111
+ textNodes += 1;
112
+ const text = String(node.textData?.characters || '').trim();
113
+ const name = String(node.name || '').trim();
114
+ // The layer name usually repeats the text's first line; only show it
115
+ // when it says something the text does not.
116
+ if (name && text && !text.startsWith(name) && !name.startsWith(text.slice(0, 20))) {
117
+ lines.push(`- **${name}**: ${text.replace(/\n/g, ' / ')}`);
118
+ } else if (text) {
119
+ lines.push(`- ${text.replace(/\n/g, ' / ')}`);
120
+ }
121
+ return;
122
+ }
123
+ if (CONTAINERS.has(type)) {
124
+ if (type !== 'DOCUMENT') {
125
+ lines.push('', heading(node, depth), '');
126
+ }
127
+ const children = doc.childrenMap?.get?.(guidKey(node.guid))
128
+ || doc.childrenMap?.[guidKey(node.guid)]
129
+ || [];
130
+ for (const child of children) walk(child, depth + (type === 'DOCUMENT' ? 0 : 1));
131
+ return;
132
+ }
133
+ skipped[type] = (skipped[type] || 0) + 1;
134
+ }
135
+
136
+ const root = (doc.nodes || []).find((n) => n.type === 'DOCUMENT');
137
+ if (root) walk(root, 0);
138
+
139
+ const title = doc.meta?.file_name || sourceName;
140
+ const head = [`# ${title}`];
141
+ const skippedText = Object.entries(skipped)
142
+ .sort((a, b) => b[1] - a[1])
143
+ .map(([t, n]) => `${t} ${n}개`)
144
+ .join(', ');
145
+ if (skippedText) {
146
+ head.push('', `(ν…μŠ€νŠΈ μ™Έ μ‹œκ° μš”μ†ŒλŠ” μƒλž΅ν–ˆμŠ΅λ‹ˆλ‹€: ${skippedText}. μ‹œκ° 확인이 ν•„μš”ν•˜λ©΄ Figmaμ—μ„œ 원본을 μ—¬μ‹­μ‹œμ˜€.)`);
147
+ }
148
+ return { markdown: [...head, ...lines, ''].join('\n'), textNodes };
149
+ }
150
+
151
+ /**
152
+ * Convert one `.fig` file. Same result contract as the Python converter:
153
+ * `{ ok, markdown, note, truncated, rows, pages, markup_bytes }` or
154
+ * `{ ok: false, reason, detail }`.
155
+ *
156
+ * `markup_bytes` is 0 on purpose. The office formats price their alternative
157
+ * as "unzip and wade through the XML", but a .fig unzips to another binary β€”
158
+ * there is no readable fallback, so there is no honest baseline to claim and
159
+ * these conversions count as documents handled rather than money saved.
160
+ */
161
+ async function convertFig(filePath, userDataDir) {
162
+ const parser = loadParser(userDataDir);
163
+ if (!parser) return { ok: false, reason: 'no-figparser' };
164
+ let doc;
165
+ try {
166
+ const buf = fs.readFileSync(filePath);
167
+ // Two vintages of the same extension: current exports are a zip wrapping
168
+ // canvas.fig, older ones are the bare fig-kiwi stream (magic "fig-kiwi").
169
+ // Verified on a real 8.4MB community kit that only the binary path reads.
170
+ doc = buf[0] === 0x50 && buf[1] === 0x4b
171
+ ? await parser.parseFig(buf)
172
+ : await parser.parseFigBinary(buf);
173
+ } catch (e) {
174
+ return { ok: false, reason: 'convert-failed', detail: String(e.message || e).slice(0, 300) };
175
+ }
176
+ const { markdown, textNodes } = renderMarkdown(doc, path.basename(filePath, '.fig'));
177
+ if (!textNodes) {
178
+ // A design file with no words converts to an empty outline, which would
179
+ // read as "the document says nothing" β€” a worse claim than "unreadable".
180
+ return { ok: false, reason: 'no-text', detail: 'the file has no text nodes' };
181
+ }
182
+ return { ok: true, markdown, note: null, truncated: false, rows: 0, pages: 0, markup_bytes: 0 };
183
+ }
184
+
185
+ module.exports = {
186
+ FIG_PARSER_SPEC,
187
+ managedFigDir,
188
+ installFigParser,
189
+ convertFig,
190
+ renderMarkdown,
191
+ };
@@ -0,0 +1,63 @@
1
+ /**
2
+ * first-run-note β€” a one-time pointer to where a feature is explained.
3
+ *
4
+ * A CLI that advertises on every invocation stops being a tool, so this fires
5
+ * ONCE per note key and then never again: the shown-at timestamp is persisted
6
+ * next to the other state files and checked before anything is printed.
7
+ *
8
+ * Opt out entirely with CTS_NO_NOTE=1 (also honoured by anything that pipes
9
+ * our output somewhere it does not belong).
10
+ */
11
+
12
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { userDataDir } from './paths.js';
15
+
16
+ export function firstRunStatePath() {
17
+ return join(userDataDir(), 'first-run.json');
18
+ }
19
+
20
+ function load() {
21
+ try {
22
+ const s = JSON.parse(readFileSync(firstRunStatePath(), 'utf8'));
23
+ return s && typeof s === 'object' ? s : {};
24
+ } catch {
25
+ return {};
26
+ }
27
+ }
28
+
29
+ function markShown(key, now) {
30
+ const state = load();
31
+ state[key] = now;
32
+ const dir = userDataDir();
33
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
34
+ writeFileSync(firstRunStatePath(), JSON.stringify(state) + '\n');
35
+ }
36
+
37
+ /** True only the first time this key is asked about. Best-effort β€” a
38
+ * read-only state dir just means the note repeats, never that we crash. */
39
+ export function shouldShowOnce(key, { now = Date.now() } = {}) {
40
+ if (process.env.CTS_NO_NOTE === '1') return false;
41
+ if (load()[key]) return false;
42
+ try {
43
+ markShown(key, now);
44
+ } catch {
45
+ /* state dir unwritable β€” show it, do not fail the command */
46
+ }
47
+ return true;
48
+ }
49
+
50
+ const NOTES = {
51
+ 'route-scan': {
52
+ ko: 'πŸ“Ί 이 κΈ°λŠ₯을 μ„€λͺ…ν•œ μ˜μƒ: https://www.youtube.com/@DeepPulseKR (이 μ•ˆλ‚΄λŠ” ν•œ 번만 ν‘œμ‹œλ©λ‹ˆλ‹€)',
53
+ en: 'πŸ“Ί How this works, in 3 minutes: https://www.youtube.com/@DeepPulseEN (shown once)',
54
+ },
55
+ };
56
+
57
+ /** Print the one-time note for `key`, or nothing. */
58
+ export function printOnce(key, lang = 'en') {
59
+ const note = NOTES[key];
60
+ if (!note || !shouldShowOnce(key)) return;
61
+ console.log('');
62
+ console.log(lang === 'ko' ? note.ko : note.en);
63
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Shared "resets in" countdown formatter. The 7-day window can be ~5 days
3
+ * away, so we promote whole-day spans into a `Xd Yh` shape; otherwise it's
4
+ * `Xh Ym` for β‰₯1h and `Xm` under that.
5
+ *
6
+ * @param {number} resetsAt - Unix-epoch seconds when the window resets.
7
+ * @param {Date} [now=new Date()]
8
+ * @returns {string|null} formatted countdown, or null when the input isn't usable.
9
+ */
10
+ export function formatResetIn(resetsAt, now = new Date()) {
11
+ if (!Number.isFinite(resetsAt)) return null;
12
+ const remainingSec = Math.max(0, resetsAt - Math.floor(now.getTime() / 1000));
13
+ if (remainingSec <= 0) return '0m';
14
+ const d = Math.floor(remainingSec / 86400);
15
+ const h = Math.floor((remainingSec % 86400) / 3600);
16
+ const m = Math.floor((remainingSec % 3600) / 60);
17
+ if (d > 0) return `${d}d ${h}h`;
18
+ if (h > 0) return `${h}h ${m}m`;
19
+ return `${m}m`;
20
+ }
21
+
22
+ /**
23
+ * Wall-clock time at which the window resets, in the user's local timezone.
24
+ * Same-day resets show `HH:MM`; resets that cross midnight prepend the weekday
25
+ * (e.g. `Sat 21:30`) so a glance at the statusline doesn't mislead.
26
+ *
27
+ * @param {number} resetsAt - Unix-epoch seconds.
28
+ * @param {Date} [now=new Date()]
29
+ * @returns {string|null}
30
+ */
31
+ export function formatResetClock(resetsAt, now = new Date()) {
32
+ if (!Number.isFinite(resetsAt)) return null;
33
+ const reset = new Date(resetsAt * 1000);
34
+ if (Number.isNaN(reset.getTime())) return null;
35
+ const hh = String(reset.getHours()).padStart(2, '0');
36
+ const mm = String(reset.getMinutes()).padStart(2, '0');
37
+ const sameDay =
38
+ reset.getFullYear() === now.getFullYear() &&
39
+ reset.getMonth() === now.getMonth() &&
40
+ reset.getDate() === now.getDate();
41
+ if (sameDay) return `${hh}:${mm}`;
42
+ const dow = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][reset.getDay()];
43
+ return `${dow} ${hh}:${mm}`;
44
+ }
@@ -0,0 +1,8 @@
1
+ export function formatReport({ trend }) {
2
+ const header = 'date,hit_rate,api_calls,cache_read,cache_creation,ephemeral_5m,ephemeral_1h,input,output';
3
+ const rows = trend.map(
4
+ (d) =>
5
+ `${d.date},${d.hitRate.toFixed(4)},${d.apiCalls},${d.cacheRead},${d.cacheCreation},${d.ephemeral5m},${d.ephemeral1h},${d.input},${d.output}`,
6
+ );
7
+ return [header, ...rows].join('\n');
8
+ }
@@ -0,0 +1,3 @@
1
+ export function formatReport(data) {
2
+ return JSON.stringify(data, null, 2);
3
+ }