@searls/turbocommit 0.10.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/lib/session.js ADDED
@@ -0,0 +1,298 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const { ensureDir } = require('./io')
4
+
5
+ const BREADCRUMB_THRESHOLD_MS = 2000
6
+ const STALE_TTL_MS = 24 * 60 * 60 * 1000
7
+
8
+ function turbocommitDir (root) {
9
+ return path.join(root, '.git', 'turbocommit')
10
+ }
11
+
12
+ function breadcrumbDir (root) {
13
+ return path.join(turbocommitDir(root), 'breadcrumbs')
14
+ }
15
+
16
+ function chainDir (root) {
17
+ return path.join(turbocommitDir(root), 'chains')
18
+ }
19
+
20
+ function pendingDir (root) {
21
+ return path.join(turbocommitDir(root), 'pending')
22
+ }
23
+
24
+ function watermarkDir (root) {
25
+ return path.join(turbocommitDir(root), 'watermarks')
26
+ }
27
+
28
+ /**
29
+ * SessionEnd handler. Writes a breadcrumb for the ending session.
30
+ */
31
+ function handleSessionEnd (input, root) {
32
+ if (!root) return
33
+
34
+ let hookInput
35
+ try {
36
+ hookInput = JSON.parse(input)
37
+ } catch {
38
+ return
39
+ }
40
+
41
+ const sessionId = hookInput.session_id
42
+ if (!sessionId) return
43
+
44
+ const dir = breadcrumbDir(root)
45
+ ensureDir(dir)
46
+ const data = { session_id: sessionId, timestamp: Date.now() }
47
+ fs.writeFileSync(path.join(dir, sessionId + '.json'), JSON.stringify(data) + '\n')
48
+ }
49
+
50
+ /**
51
+ * SessionStart handler. Matches breadcrumbs for /clear and resume continuations.
52
+ */
53
+ function handleSessionStart (input, root) {
54
+ if (!root) return
55
+
56
+ let hookInput
57
+ try {
58
+ hookInput = JSON.parse(input)
59
+ } catch {
60
+ return
61
+ }
62
+
63
+ const sessionId = hookInput.session_id
64
+ if (!sessionId) return
65
+
66
+ const source = hookInput.source
67
+ if (source !== 'clear' && source !== 'resume') return
68
+
69
+ const dir = breadcrumbDir(root)
70
+ if (!fs.existsSync(dir)) return
71
+
72
+ // Scan breadcrumbs for closest match
73
+ const now = Date.now()
74
+ let best = null
75
+ let bestGap = Infinity
76
+
77
+ let files
78
+ try {
79
+ files = fs.readdirSync(dir)
80
+ } catch {
81
+ return
82
+ }
83
+
84
+ for (const file of files) {
85
+ if (!file.endsWith('.json')) continue
86
+ try {
87
+ const data = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'))
88
+ const gap = Math.abs(now - data.timestamp)
89
+ if (gap < bestGap) {
90
+ bestGap = gap
91
+ best = data
92
+ }
93
+ } catch {
94
+ continue
95
+ }
96
+ }
97
+
98
+ if (!best || bestGap > BREADCRUMB_THRESHOLD_MS) return
99
+
100
+ // Claim the breadcrumb (delete it so no other session grabs it)
101
+ try {
102
+ fs.unlinkSync(path.join(dir, best.session_id + '.json'))
103
+ } catch {}
104
+
105
+ // Read predecessor's chain to get full ancestry
106
+ const predecessorChain = readChain(root, best.session_id)
107
+ const ancestors = [best.session_id, ...(predecessorChain ? predecessorChain.ancestors : [])]
108
+
109
+ // Write chain for this session
110
+ const cDir = chainDir(root)
111
+ ensureDir(cDir)
112
+ const chain = { parent: best.session_id, ancestors }
113
+ fs.writeFileSync(path.join(cDir, sessionId + '.json'), JSON.stringify(chain) + '\n')
114
+ }
115
+
116
+ function readChain (root, sessionId) {
117
+ try {
118
+ return JSON.parse(fs.readFileSync(path.join(chainDir(root), sessionId + '.json'), 'utf8'))
119
+ } catch {
120
+ return null
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Get ordered ancestor list for a session (nearest first).
126
+ */
127
+ function getAncestors (root, sessionId) {
128
+ const chain = readChain(root, sessionId)
129
+ return chain ? chain.ancestors : []
130
+ }
131
+
132
+ /**
133
+ * Save formatted transcript to pending directory for later pickup.
134
+ */
135
+ let pendingSeq = 0
136
+ function savePending (root, sessionId, transcript) {
137
+ const dir = path.join(pendingDir(root), sessionId)
138
+ ensureDir(dir)
139
+ const timestamp = String(Date.now()) + '-' + String(pendingSeq++).padStart(4, '0')
140
+ fs.writeFileSync(path.join(dir, timestamp + '.txt'), transcript)
141
+ }
142
+
143
+ /**
144
+ * Collect pending transcripts for a list of session IDs, in order
145
+ * (oldest ancestor first). Returns array of strings.
146
+ */
147
+ function collectPending (root, sessionIds) {
148
+ const results = []
149
+ for (const sid of sessionIds) {
150
+ const dir = path.join(pendingDir(root), sid)
151
+ let files
152
+ try {
153
+ files = fs.readdirSync(dir).sort()
154
+ } catch {
155
+ continue
156
+ }
157
+ for (const file of files) {
158
+ if (!file.endsWith('.txt')) continue
159
+ try {
160
+ const content = fs.readFileSync(path.join(dir, file), 'utf8')
161
+ if (content.trim()) results.push(content)
162
+ } catch {
163
+ continue
164
+ }
165
+ }
166
+ }
167
+ return results
168
+ }
169
+
170
+ /**
171
+ * Read watermark for a session. Returns { pairs, commit } or null.
172
+ */
173
+ function readWatermark (root, sessionId) {
174
+ try {
175
+ return JSON.parse(fs.readFileSync(path.join(watermarkDir(root), sessionId + '.json'), 'utf8'))
176
+ } catch {
177
+ return null
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Save watermark after a commit: pair count and commit SHA.
183
+ */
184
+ function saveWatermark (root, sessionId, pairs, commit) {
185
+ const dir = watermarkDir(root)
186
+ ensureDir(dir)
187
+ fs.writeFileSync(path.join(dir, sessionId + '.json'), JSON.stringify({ pairs, commit }) + '\n')
188
+ }
189
+
190
+ /**
191
+ * Resolve parent commit SHA for continuation references.
192
+ * Checks own watermark first, then walks chain ancestors nearest-first.
193
+ */
194
+ function resolveParentCommit (root, sessionId) {
195
+ // Own watermark takes priority (same-session previous commit)
196
+ const own = readWatermark(root, sessionId)
197
+ if (own) return own.commit
198
+
199
+ // Walk chain ancestors nearest-first
200
+ const ancestors = getAncestors(root, sessionId)
201
+ for (const aid of ancestors) {
202
+ const wm = readWatermark(root, aid)
203
+ if (wm) return wm.commit
204
+ }
205
+ return null
206
+ }
207
+
208
+ /**
209
+ * Delete consumed pending + chain files after commit.
210
+ */
211
+ function cleanupConsumed (root, sessionIds) {
212
+ for (const sid of sessionIds) {
213
+ // Remove pending dir
214
+ const dir = path.join(pendingDir(root), sid)
215
+ try {
216
+ const files = fs.readdirSync(dir)
217
+ for (const file of files) {
218
+ fs.unlinkSync(path.join(dir, file))
219
+ }
220
+ fs.rmdirSync(dir)
221
+ } catch {}
222
+
223
+ // Chain files are preserved — resolveParentCommit needs them
224
+ // to walk cross-session lineage. Stale cleanup handles them after 24h.
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Remove stale orphaned files older than maxAgeMs (default 24h).
230
+ */
231
+ function cleanupStale (root, maxAgeMs) {
232
+ const ttl = maxAgeMs != null ? maxAgeMs : STALE_TTL_MS
233
+ const now = Date.now()
234
+ const base = turbocommitDir(root)
235
+
236
+ for (const sub of ['breadcrumbs', 'chains', 'tracking', 'watermarks']) {
237
+ const dir = path.join(base, sub)
238
+ let files
239
+ try {
240
+ files = fs.readdirSync(dir)
241
+ } catch {
242
+ continue
243
+ }
244
+ for (const file of files) {
245
+ const fp = path.join(dir, file)
246
+ try {
247
+ const stat = fs.statSync(fp)
248
+ if (now - stat.mtimeMs > ttl) {
249
+ fs.unlinkSync(fp)
250
+ }
251
+ } catch {}
252
+ }
253
+ }
254
+
255
+ // Clean stale pending directories
256
+ const pDir = path.join(base, 'pending')
257
+ let pdirs
258
+ try {
259
+ pdirs = fs.readdirSync(pDir)
260
+ } catch {
261
+ return
262
+ }
263
+ for (const sid of pdirs) {
264
+ const dir = path.join(pDir, sid)
265
+ try {
266
+ const stat = fs.statSync(dir)
267
+ if (!stat.isDirectory()) continue
268
+ if (now - stat.mtimeMs > ttl) {
269
+ const files = fs.readdirSync(dir)
270
+ for (const file of files) {
271
+ fs.unlinkSync(path.join(dir, file))
272
+ }
273
+ fs.rmdirSync(dir)
274
+ }
275
+ } catch {}
276
+ }
277
+ }
278
+
279
+ module.exports = {
280
+ handleSessionEnd,
281
+ handleSessionStart,
282
+ getAncestors,
283
+ savePending,
284
+ collectPending,
285
+ cleanupConsumed,
286
+ cleanupStale,
287
+ readChain,
288
+ readWatermark,
289
+ saveWatermark,
290
+ resolveParentCommit,
291
+ breadcrumbDir,
292
+ chainDir,
293
+ pendingDir,
294
+ watermarkDir,
295
+ turbocommitDir,
296
+ BREADCRUMB_THRESHOLD_MS,
297
+ STALE_TTL_MS
298
+ }
package/lib/track.js ADDED
@@ -0,0 +1,120 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const { ensureDir } = require('./io')
4
+
5
+ /**
6
+ * Directory under .git where turbocommit stores tracking state.
7
+ */
8
+ function trackingDir (root) {
9
+ return path.join(root, '.git', 'turbocommit', 'tracking')
10
+ }
11
+
12
+ function trackingPath (root, sessionId) {
13
+ return path.join(trackingDir(root), sessionId + '.jsonl')
14
+ }
15
+
16
+ /**
17
+ * Keys to probe in tool_input for a file path (MCP tools, Write, Edit, etc.)
18
+ */
19
+ const FILE_PATH_KEYS = ['file_path', 'filePath', 'path', 'file', 'notebook_path']
20
+
21
+ /**
22
+ * Extract a file path from tool_input, heuristically checking known keys.
23
+ */
24
+ function extractFilePath (toolInput) {
25
+ if (!toolInput || typeof toolInput !== 'object') return null
26
+ for (const key of FILE_PATH_KEYS) {
27
+ if (typeof toolInput[key] === 'string' && toolInput[key].length > 0) {
28
+ return toolInput[key]
29
+ }
30
+ }
31
+ return null
32
+ }
33
+
34
+ /**
35
+ * PreToolUse handler. Appends a tracking entry for potentially-modifying tools.
36
+ * Always exits 0 (never blocks tool execution).
37
+ */
38
+ function handleTrack (input, root) {
39
+ if (!root) return
40
+
41
+ let hookInput
42
+ try {
43
+ hookInput = JSON.parse(input)
44
+ } catch {
45
+ return
46
+ }
47
+
48
+ const sessionId = hookInput.session_id
49
+ if (!sessionId) return
50
+
51
+ const toolName = hookInput.tool_name
52
+ if (!toolName) return
53
+
54
+ const toolInput = hookInput.tool_input || {}
55
+
56
+ const entry = { tool: toolName, t: Date.now() }
57
+
58
+ // Extract file path for file-modifying tools
59
+ const filePath = extractFilePath(toolInput)
60
+ if (filePath) {
61
+ entry.file = filePath
62
+ }
63
+
64
+ // For Bash, record the command
65
+ if (toolName === 'Bash' && typeof toolInput.command === 'string') {
66
+ entry.command = toolInput.command
67
+ }
68
+
69
+ // Skip Bash with no command (malformed input). All other non-Bash tools
70
+ // passed the PreToolUse matcher, so they're known modifying tools even if
71
+ // we can't extract a specific file path (e.g. MultiEdit nests paths in edits[]).
72
+ if (toolName === 'Bash' && typeof toolInput.command !== 'string') return
73
+
74
+ const file = trackingPath(root, sessionId)
75
+ ensureDir(path.dirname(file))
76
+ fs.appendFileSync(file, JSON.stringify(entry) + '\n')
77
+ }
78
+
79
+ /**
80
+ * Check whether a session has tracked any file-modifying tool calls.
81
+ * Bash entries alone don't count — Bash is too noisy (ls, git status, etc.)
82
+ * and we can't reliably distinguish read-only from write commands.
83
+ * The definitive signal comes from Write/Edit/NotebookEdit/MCP tools.
84
+ */
85
+ function hasTrackedModifications (root, sessionId) {
86
+ const file = trackingPath(root, sessionId)
87
+ try {
88
+ const data = fs.readFileSync(file, 'utf8')
89
+ if (!data) return false
90
+ const lines = data.trim().split('\n')
91
+ return lines.some(line => {
92
+ try {
93
+ const entry = JSON.parse(line)
94
+ return entry.tool !== 'Bash'
95
+ } catch {
96
+ return false
97
+ }
98
+ })
99
+ } catch {
100
+ return false
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Delete tracking file after commit or cleanup.
106
+ */
107
+ function cleanupTracking (root, sessionId) {
108
+ try {
109
+ fs.unlinkSync(trackingPath(root, sessionId))
110
+ } catch {}
111
+ }
112
+
113
+ module.exports = {
114
+ handleTrack,
115
+ hasTrackedModifications,
116
+ cleanupTracking,
117
+ extractFilePath,
118
+ trackingDir,
119
+ trackingPath
120
+ }
@@ -0,0 +1,133 @@
1
+ const fs = require('fs')
2
+
3
+ /**
4
+ * Parse a JSONL transcript file into prompt/response pairs.
5
+ * Returns array of { prompt, response } objects.
6
+ */
7
+ function parseTranscript (filePath) {
8
+ if (!filePath || !fs.existsSync(filePath)) return []
9
+
10
+ const lines = fs.readFileSync(filePath, 'utf8').split('\n')
11
+ const state = { pairs: [], prompt: null, response: '' }
12
+
13
+ for (const line of lines) {
14
+ if (!line.trim()) continue
15
+ let entry
16
+ try {
17
+ entry = JSON.parse(line)
18
+ } catch {
19
+ continue
20
+ }
21
+
22
+ if (entry.type === 'user' && typeof entry.message?.content === 'string') {
23
+ if (state.prompt !== null) {
24
+ state.pairs.push({ prompt: state.prompt, response: state.response })
25
+ }
26
+ state.prompt = entry.message.content
27
+ state.response = ''
28
+ } else if (entry.type === 'assistant' && Array.isArray(entry.message?.content)) {
29
+ const hasToolUse = entry.message.content.some(b => b.type === 'tool_use')
30
+ if (hasToolUse) continue
31
+ const texts = entry.message.content
32
+ .filter(b => b.type === 'text')
33
+ .map(b => b.text)
34
+ state.response += texts.join('')
35
+ }
36
+ }
37
+
38
+ if (state.prompt !== null) {
39
+ state.pairs.push({ prompt: state.prompt, response: state.response })
40
+ }
41
+
42
+ return state.pairs
43
+ }
44
+
45
+ /**
46
+ * Format a single prompt/response pair.
47
+ */
48
+ function formatPair (p) {
49
+ return `Prompt:\n${p.prompt}\n\nResponse:\n${p.response}`
50
+ }
51
+
52
+ /**
53
+ * Format prompt/response pairs into a commit body.
54
+ */
55
+ function formatBody (pairs) {
56
+ if (pairs.length === 0) return '(no transcript)'
57
+ return pairs.map(formatPair).join('\n\n---\n\n')
58
+ }
59
+
60
+ /**
61
+ * Format a condensed transcript for title generation.
62
+ * Returns the full transcript when it fits within budget, otherwise
63
+ * samples the first, middle, and last pairs with gap markers.
64
+ * Individual prompts and responses are capped to keep the sample compact.
65
+ */
66
+ function formatTitleTranscript (pairs, budget) {
67
+ budget = budget || 20000
68
+ if (pairs.length === 0) return '(no transcript)'
69
+
70
+ const full = formatBody(pairs)
71
+ if (full.length <= budget) return full
72
+
73
+ const sep = '\n\n---\n\n'
74
+ const cap = (text, max) => text.length > max ? text.slice(0, max) + '...' : text
75
+ const fmt = p => `Prompt:\n${cap(p.prompt, 500)}\n\nResponse:\n${cap(p.response, 2000)}`
76
+
77
+ // Pick first, middle, and last — deduplicated and in order
78
+ const indices = [...new Set([0, Math.floor(pairs.length / 2), pairs.length - 1])]
79
+
80
+ const parts = []
81
+ let prev = -1
82
+ for (const i of indices) {
83
+ const skipped = i - prev - 1
84
+ if (skipped > 0) parts.push(`[... ${skipped} turns omitted ...]`)
85
+ parts.push(fmt(pairs[i]))
86
+ prev = i
87
+ }
88
+
89
+ return parts.join(sep)
90
+ }
91
+
92
+ /**
93
+ * Extract a headline from the last user prompt.
94
+ * Falls back to a timestamped default.
95
+ */
96
+ function extractHeadline (pairs) {
97
+ if (pairs.length === 0) return fallbackHeadline()
98
+ const lastPrompt = pairs[pairs.length - 1].prompt
99
+ if (!lastPrompt) return fallbackHeadline()
100
+ const firstLine = lastPrompt.split('\n')[0]
101
+ return firstLine.slice(0, 72) || fallbackHeadline()
102
+ }
103
+
104
+ function fallbackHeadline () {
105
+ const now = new Date()
106
+ const pad = n => String(n).padStart(2, '0')
107
+ return `auto-commit ${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`
108
+ }
109
+
110
+ /**
111
+ * Extract the model ID from the first assistant entry in a JSONL transcript.
112
+ * Returns null if not found.
113
+ */
114
+ function extractModel (filePath) {
115
+ if (!filePath || !fs.existsSync(filePath)) return null
116
+
117
+ const lines = fs.readFileSync(filePath, 'utf8').split('\n')
118
+ for (const line of lines) {
119
+ if (!line.trim()) continue
120
+ let entry
121
+ try {
122
+ entry = JSON.parse(line)
123
+ } catch {
124
+ continue
125
+ }
126
+ if (entry.type === 'assistant' && entry.message?.model) {
127
+ return entry.message.model
128
+ }
129
+ }
130
+ return null
131
+ }
132
+
133
+ module.exports = { parseTranscript, formatPair, formatBody, formatTitleTranscript, extractHeadline, fallbackHeadline, extractModel }
package/lib/wrap.js ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Wrap prose lines at a specified width while preserving structured
3
+ * Markdown content (code blocks, tables, headers, etc.) verbatim.
4
+ */
5
+
6
+ function wrapLine (text, maxLen, indent) {
7
+ indent = indent || ''
8
+ const leadMatch = text.match(/^(\s+)/)
9
+ const lead = leadMatch ? leadMatch[1] : ''
10
+ const words = text.trimStart().split(' ')
11
+ if (words.length > 0) words[0] = lead + words[0]
12
+ const lines = []
13
+ let current = ''
14
+
15
+ for (const word of words) {
16
+ if (current === '') {
17
+ current = word
18
+ } else if (current.length + 1 + word.length <= maxLen) {
19
+ current += ' ' + word
20
+ } else {
21
+ lines.push(current)
22
+ current = indent + word
23
+ }
24
+ }
25
+ if (current) lines.push(current)
26
+ return lines.join('\n')
27
+ }
28
+
29
+ function wrapText (text, maxLineLength) {
30
+ if (!maxLineLength || typeof maxLineLength !== 'number' || maxLineLength < 1) return text
31
+ if (text === '') return text
32
+
33
+ const lines = text.split('\n')
34
+ const result = []
35
+ let inFence = false
36
+ let fencePattern = null
37
+
38
+ for (const line of lines) {
39
+ // Fenced code block toggle (backtick or tilde)
40
+ const fenceMatch = line.match(/^(`{3,}|~{3,})/)
41
+ if (fenceMatch) {
42
+ if (!inFence) {
43
+ inFence = true
44
+ fencePattern = fenceMatch[1]
45
+ result.push(line)
46
+ continue
47
+ } else if (line.startsWith(fencePattern) && line.trim() === fencePattern) {
48
+ inFence = false
49
+ fencePattern = null
50
+ result.push(line)
51
+ continue
52
+ }
53
+ }
54
+
55
+ // Inside fenced block — preserve verbatim
56
+ if (inFence) {
57
+ result.push(line)
58
+ continue
59
+ }
60
+
61
+ // Blank line
62
+ if (line === '') {
63
+ result.push(line)
64
+ continue
65
+ }
66
+
67
+ // Indented code (4+ spaces or tab)
68
+ if (/^( {4,}|\t)/.test(line)) {
69
+ result.push(line)
70
+ continue
71
+ }
72
+
73
+ // Header
74
+ if (/^#{1,6}\s/.test(line)) {
75
+ result.push(line)
76
+ continue
77
+ }
78
+
79
+ // Table row (starts with |)
80
+ if (/^\|/.test(line.trimStart())) {
81
+ result.push(line)
82
+ continue
83
+ }
84
+
85
+ // Horizontal rule
86
+ if (/^[-*_]{3,}\s*$/.test(line)) {
87
+ result.push(line)
88
+ continue
89
+ }
90
+
91
+ // Blockquote
92
+ const bqMatch = line.match(/^((?:>\s?)+)/)
93
+ if (bqMatch) {
94
+ const prefix = bqMatch[0]
95
+ const indent = bqMatch[1].replace(/\s?$/, ' ')
96
+ if (line.length <= maxLineLength) {
97
+ result.push(line)
98
+ } else {
99
+ const content = line.slice(prefix.length)
100
+ result.push(wrapLine(prefix + content, maxLineLength, indent))
101
+ }
102
+ continue
103
+ }
104
+
105
+ // List item
106
+ const listMatch = line.match(/^(\s*)([-*]|\d+\.)\s/)
107
+ if (listMatch) {
108
+ const prefix = listMatch[0]
109
+ const indent = ' '.repeat(prefix.length)
110
+ if (line.length <= maxLineLength) {
111
+ result.push(line)
112
+ } else {
113
+ result.push(wrapLine(line, maxLineLength, indent))
114
+ }
115
+ continue
116
+ }
117
+
118
+ // Prose — wrap at word boundaries
119
+ if (line.length <= maxLineLength) {
120
+ result.push(line)
121
+ } else {
122
+ result.push(wrapLine(line, maxLineLength, ''))
123
+ }
124
+ }
125
+
126
+ return result.join('\n')
127
+ }
128
+
129
+ module.exports = { wrapText }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@searls/turbocommit",
3
+ "version": "0.10.0",
4
+ "description": "Auto-commit after every Claude Code turn",
5
+ "bin": {
6
+ "turbocommit": "./cli.js"
7
+ },
8
+ "engines": {
9
+ "node": ">=18.0.0"
10
+ },
11
+ "files": [
12
+ "cli.js",
13
+ "lib/"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/searlsco/turbocommit.git"
18
+ },
19
+ "keywords": [
20
+ "claude",
21
+ "claude-code",
22
+ "git",
23
+ "auto-commit"
24
+ ],
25
+ "scripts": {
26
+ "test": "node --test test/*.test.js",
27
+ "lint": "npx standard --fix"
28
+ },
29
+ "devDependencies": {
30
+ "standard": "^17.1.0"
31
+ },
32
+ "license": "MIT"
33
+ }