moflo 4.12.8 → 4.12.10

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.
@@ -31,6 +31,14 @@
31
31
  * "stats": { added, deleted, fileCount, declAdded, declRemoved, tsjsLOC, tsjsNetDecls, otherNetAdded, ... }
32
32
  * }
33
33
  *
34
+ * The diff it measures spans committed-since-base, working-tree, AND untracked
35
+ * non-ignored files — an untracked file is a change the branch will carry, and
36
+ * omitting it undercounts the diff exactly as a swallowed read error does.
37
+ * When a read it needed did not happen, `stats.diffUnavailable` is set and the
38
+ * decision routes to a review tier: the classifier cannot distinguish "no
39
+ * changes" from "I could not read the changes", and only the first is safe to
40
+ * call TRIVIAL (#1451).
41
+ *
34
42
  * Usage:
35
43
  * node bin/simplify-classify.cjs # auto-detects default branch
36
44
  * node bin/simplify-classify.cjs --base develop # explicit override
@@ -42,6 +50,27 @@
42
50
  'use strict';
43
51
 
44
52
  const { execSync } = require('child_process');
53
+ const fs = require('fs');
54
+ const path = require('path');
55
+
56
+ // execSync defaults to a 1 MiB stdout buffer and a real branch diff clears that
57
+ // routinely (#1451 measured 2,113,712 bytes). Overflow throws ENOBUFS, which
58
+ // used to be swallowed into an empty diff — "TRIVIAL, nothing to review" on a
59
+ // branch with plenty to review. 64 MiB puts the cliff well past any diff a
60
+ // human opens a PR for; past it, the classifier now says so instead of
61
+ // reporting zero.
62
+ const EXEC_MAX_BUFFER = 64 * 1024 * 1024;
63
+
64
+ // Cap on how much of an untracked file is slurped to synthesize its new-file
65
+ // diff. Well past any hand-written source file; anything larger is treated like
66
+ // a binary (counted as a new file with no added lines) rather than read.
67
+ const UNTRACKED_MAX_BYTES = 8 * 1024 * 1024;
68
+
69
+ // Total budget for synthesized untracked-file content. A repo with a huge
70
+ // un-ignored directory must not be slurped into memory wholesale — past this
71
+ // the remaining files are recorded as new files and the result is flagged
72
+ // unmeasurable, which forces review rather than quietly undercounting.
73
+ const UNTRACKED_TOTAL_BUDGET = 32 * 1024 * 1024;
45
74
 
46
75
  // Paths where new logic warrants the 3-agent fan-out.
47
76
  // Mechanical edits inside these paths are still SMALL; only adding/removing
@@ -98,14 +127,43 @@ function noEscalate() {
98
127
  return { suggested: false, target: null, reason: null };
99
128
  }
100
129
 
130
+ /**
131
+ * Run a git command, returning its stdout — or `null` if the command failed.
132
+ *
133
+ * `null` rather than `''` is load-bearing: a caller that cannot tell "git said
134
+ * nothing" from "git never answered" will report an unreadable diff as an empty
135
+ * one, and an empty diff is the one thing it is safe to call TRIVIAL (#1451).
136
+ */
101
137
  function safeExec(cmd, opts) {
102
138
  try {
103
139
  return execSync(cmd, {
104
140
  encoding: 'utf-8',
105
141
  stdio: ['pipe', 'pipe', 'pipe'],
142
+ maxBuffer: EXEC_MAX_BUFFER,
106
143
  ...(opts && opts.cwd ? { cwd: opts.cwd } : {}),
107
144
  });
108
- } catch { return ''; }
145
+ } catch { return null; }
146
+ }
147
+
148
+ /**
149
+ * Is there a repo with at least one commit here? A git failure only means
150
+ * "there was something we could not measure" when there is history to read —
151
+ * outside a repo, or in a fresh `git init` before the first commit, there is
152
+ * genuinely no diff to miss, and forcing a review fan-out over nothing would be
153
+ * its own defect.
154
+ */
155
+ function hasGitHistory(cwd) {
156
+ return safeExec('git rev-parse --verify HEAD', cwd ? { cwd } : undefined) !== null;
157
+ }
158
+
159
+ /**
160
+ * One `git rev-parse` per classification, shared by both diff readers. They
161
+ * consult it only on their failure paths — and a directory that is not a repo
162
+ * makes every read fail, so without sharing, the cheapest case pays twice.
163
+ */
164
+ function makeHistoryProbe(cwd) {
165
+ let answer;
166
+ return () => (answer === undefined ? (answer = hasGitHistory(cwd)) : answer);
109
167
  }
110
168
 
111
169
  // Detect the consumer's default branch. Hardcoding 'main' silently miscalibrates
@@ -119,7 +177,7 @@ function detectDefaultBranch(cwd) {
119
177
  const opts = cwd ? { cwd } : undefined;
120
178
 
121
179
  // Preferred: origin/HEAD points to whatever the remote considers default.
122
- const symbolic = safeExec('git symbolic-ref --short refs/remotes/origin/HEAD', opts).trim();
180
+ const symbolic = (safeExec('git symbolic-ref --short refs/remotes/origin/HEAD', opts) || '').trim();
123
181
  if (symbolic.startsWith('origin/')) {
124
182
  const v = symbolic.slice('origin/'.length);
125
183
  if (cwd === undefined) _cachedDefaultBranch = v;
@@ -127,7 +185,7 @@ function detectDefaultBranch(cwd) {
127
185
  }
128
186
 
129
187
  // Fallback: local init.defaultBranch (set by `git init -b <name>` or config).
130
- const configured = safeExec('git config --get init.defaultBranch', opts).trim();
188
+ const configured = (safeExec('git config --get init.defaultBranch', opts) || '').trim();
131
189
  if (configured) {
132
190
  if (cwd === undefined) _cachedDefaultBranch = configured;
133
191
  return configured;
@@ -142,12 +200,95 @@ function _resetCacheForTest() {
142
200
  _cachedDefaultBranch = null;
143
201
  }
144
202
 
145
- function readDiffFromGit(base, cwd) {
203
+ /**
204
+ * Read the tracked half of the diff: committed-since-base + working-tree.
205
+ * Returns `{ text, unreadable, reason }` — `unreadable` means a read we needed
206
+ * did not happen, so `text` is an undercount and must not be trusted as zero.
207
+ */
208
+ function readDiffFromGit(base, cwd, historyProbe) {
146
209
  const opts = cwd ? { cwd } : undefined;
147
- // Combined diff: committed-since-base + working-tree
148
210
  const committed = safeExec(`git diff ${base}...HEAD`, opts);
149
211
  const working = safeExec('git diff HEAD', opts);
150
- return committed + (working ? '\n' + working : '');
212
+ const text = (committed || '') + (working ? '\n' + working : '');
213
+
214
+ if (committed !== null && working !== null) return { text, unreadable: false };
215
+ if (!(historyProbe ? historyProbe() : hasGitHistory(cwd))) return { text, unreadable: false };
216
+
217
+ const failed = [];
218
+ if (committed === null) failed.push(`git diff ${base}...HEAD`);
219
+ if (working === null) failed.push('git diff HEAD');
220
+ return { text, unreadable: true, reason: `${failed.join(' and ')} failed` };
221
+ }
222
+
223
+ /**
224
+ * Read the untracked half of the diff.
225
+ *
226
+ * Untracked files appear in no `git diff` output at all, so a branch of
227
+ * brand-new files reads as a far smaller change than it is — 12 new CRUD files
228
+ * classified SMALL until someone staged them (#1451). Synthesize a new-file
229
+ * entry per untracked, non-ignored file so `parseDiff` counts it exactly as it
230
+ * would once staged.
231
+ *
232
+ * Built from Node file reads rather than `git diff --no-index` against a null
233
+ * device, which would need `/dev/null` vs `NUL` branching (Rule #1). The index
234
+ * is never touched.
235
+ */
236
+ function readUntrackedDiff(cwd, historyProbe) {
237
+ const root = cwd || process.cwd();
238
+ const out = safeExec('git ls-files --others --exclude-standard -z', cwd ? { cwd } : undefined);
239
+ if (out === null) {
240
+ return (historyProbe ? historyProbe() : hasGitHistory(cwd))
241
+ ? { text: '', unreadable: true, reason: 'git ls-files --others failed' }
242
+ : { text: '', unreadable: false };
243
+ }
244
+
245
+ const parts = [];
246
+ let budgetSpent = 0;
247
+ let overBudget = false;
248
+ // -z keeps paths raw (no shell quoting of unusual characters). git emits them
249
+ // POSIX-separated on every platform, so they need no separator translation —
250
+ // only path.resolve to reach the file on disk.
251
+ for (const rel of out.split('\0')) {
252
+ if (!rel) continue;
253
+ const header = `diff --git a/${rel} b/${rel}\nnew file mode 100644\n`;
254
+
255
+ let body = null;
256
+ try {
257
+ const abs = path.resolve(root, rel);
258
+ // lstat, not stat: following an untracked symlink would read and count
259
+ // the TARGET's content — mismeasuring the diff, and pulling bytes from
260
+ // wherever the link points, which may be outside the working tree.
261
+ const stat = fs.lstatSync(abs);
262
+ if (stat.isFile() && stat.size <= UNTRACKED_MAX_BYTES && budgetSpent + stat.size <= UNTRACKED_TOTAL_BUDGET) {
263
+ const buf = fs.readFileSync(abs);
264
+ budgetSpent += stat.size;
265
+ if (!buf.includes(0)) body = buf.toString('utf-8');
266
+ } else if (stat.isFile()) {
267
+ overBudget = overBudget || budgetSpent + stat.size > UNTRACKED_TOTAL_BUDGET;
268
+ }
269
+ } catch { /* vanished or unreadable — header only */ }
270
+
271
+ if (body === null) {
272
+ // Binary, symlink, oversized, or unreadable. git emits no `+` lines for
273
+ // these either, so the file still counts toward fileCount/newFiles with
274
+ // zero added lines — the honest measurement, not a swallowed one.
275
+ parts.push(`${header}Binary files /dev/null and b/${rel} differ\n`);
276
+ continue;
277
+ }
278
+
279
+ // Split on \n and drop the trailing empty element from a final newline;
280
+ // CRLF files keep their \r on each line, which parseDiff trims before
281
+ // testing for declarations.
282
+ const lines = body.split('\n');
283
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
284
+ parts.push(`${header}--- /dev/null\n+++ b/${rel}\n@@ -0,0 +1,${lines.length} @@\n`);
285
+ for (const ln of lines) parts.push(`+${ln}\n`);
286
+ }
287
+
288
+ const text = parts.join('');
289
+ return overBudget
290
+ ? { text, unreadable: true, reason: 'untracked files exceeded the diff-synthesis budget' }
291
+ : { text, unreadable: false };
151
292
  }
152
293
 
153
294
  /**
@@ -240,14 +381,19 @@ function parseDiff(diff) {
240
381
  }
241
382
 
242
383
  /**
243
- * Pure decision function. Takes parsed stats, returns dispatch decision.
244
- * No I/O. Easy to unit-test with synthetic stats.
384
+ * Route a diff we actually managed to measure. Pure — no I/O. Callers reach
385
+ * this through `decide`, which first handles the case where the measurement
386
+ * itself failed.
245
387
  */
246
- function decide(stats) {
388
+ function decideMeasured(stats) {
247
389
  const reasoning = [];
248
390
  const totalChange = stats.added + stats.deleted;
249
391
 
250
- if (totalChange === 0) {
392
+ // Only a diff with no files at all is genuinely empty. A diff carrying files
393
+ // but no +/- lines — binary assets, pure renames, mode changes, an untracked
394
+ // binary — is a real change git simply does not express as lines, and calling
395
+ // it "nothing to review" is the same undercount as swallowing a read error.
396
+ if (totalChange === 0 && stats.fileCount === 0) {
251
397
  return { tier: 'TRIVIAL', model: 'sonnet', agentCount: 0, escalate: noEscalate(), reasoning: ['empty diff — nothing to review'], stats };
252
398
  }
253
399
 
@@ -345,13 +491,46 @@ function decide(stats) {
345
491
  return { tier: 'SMALL', model: 'sonnet', agentCount: 1, escalate: noEscalate(), reasoning, stats };
346
492
  }
347
493
 
494
+ /**
495
+ * Pure decision function. Takes parsed stats, returns dispatch decision.
496
+ * No I/O. Easy to unit-test with synthetic stats.
497
+ *
498
+ * `stats.diffUnavailable` marks a diff the reader could not fully measure. The
499
+ * classifier cannot tell "no changes" from "I could not read the changes", and
500
+ * only the first is safe to call TRIVIAL — so an unmeasurable diff routes to a
501
+ * review tier and says why, rather than stamping the gate clean (#1451).
502
+ */
503
+ function decide(stats) {
504
+ if (!stats.diffUnavailable) return decideMeasured(stats);
505
+
506
+ const note = `diff could not be fully read (${stats.diffUnavailableReason || 'git read failed'})`
507
+ + ' — a diff the classifier cannot measure is never TRIVIAL';
508
+ // Whatever DID parse may already warrant more than the forced NORMAL floor;
509
+ // an architectural diff whose working-tree half went missing stays DEEP.
510
+ const measured = decideMeasured(stats);
511
+ if (measured.agentCount >= 3) {
512
+ return { ...measured, reasoning: [note].concat(measured.reasoning) };
513
+ }
514
+ return { tier: 'NORMAL', model: 'sonnet', agentCount: 3, escalate: noEscalate(), reasoning: [note], stats };
515
+ }
516
+
348
517
  function classifyDiff(diffText) {
349
518
  return decide(parseDiff(diffText));
350
519
  }
351
520
 
352
521
  function classifyFromGit(base, cwd) {
353
522
  const resolved = base || detectDefaultBranch(cwd);
354
- return classifyDiff(readDiffFromGit(resolved, cwd));
523
+ const historyProbe = makeHistoryProbe(cwd);
524
+ const tracked = readDiffFromGit(resolved, cwd, historyProbe);
525
+ const untracked = readUntrackedDiff(cwd, historyProbe);
526
+ const stats = parseDiff(tracked.text + (untracked.text ? '\n' + untracked.text : ''));
527
+ if (tracked.unreadable || untracked.unreadable) {
528
+ stats.diffUnavailable = true;
529
+ // Both halves can fail independently; surface every reason, not just the
530
+ // first, so the printed decision explains the whole gap.
531
+ stats.diffUnavailableReason = [tracked.reason, untracked.reason].filter(Boolean).join('; ');
532
+ }
533
+ return decide(stats);
355
534
  }
356
535
 
357
536
  if (require.main === module) {
@@ -375,4 +554,7 @@ if (require.main === module) {
375
554
  }
376
555
  }
377
556
 
378
- module.exports = { parseDiff, decide, classifyDiff, classifyFromGit, detectDefaultBranch, _resetCacheForTest };
557
+ module.exports = {
558
+ parseDiff, decide, classifyDiff, classifyFromGit,
559
+ readUntrackedDiff, detectDefaultBranch, EXEC_MAX_BUFFER, _resetCacheForTest,
560
+ };
@@ -2682,7 +2682,12 @@ const statuslineCommand = {
2682
2682
  maturityScore += 10;
2683
2683
  intelligencePct = Math.min(100, maturityScore);
2684
2684
  }
2685
- const contextPct = Math.min(100, Math.floor(learning.sessions * 5));
2685
+ // Context-window usage is only knowable from the session payload Claude Code
2686
+ // pipes to a statusline command; `flo hooks status` is a plain CLI invocation
2687
+ // and never receives one. Report null rather than a stand-in (#1453) — this
2688
+ // used to be `learning.sessions * 5`, an activity counter wearing a context
2689
+ // gauge's label, which pinned at 100% after 20 stored sessions.
2690
+ const contextPct = null;
2686
2691
  return { memoryMB, contextPct, intelligencePct, subAgents };
2687
2692
  }
2688
2693
  // Get user info
@@ -2729,7 +2734,8 @@ const statuslineCommand = {
2729
2734
  }
2730
2735
  // Compact output
2731
2736
  if (ctx.flags.compact) {
2732
- const line = `DDD:${progress.domainsCompleted}/${progress.totalDomains} CVE:${security.cvesFixed}/${security.totalCves} Swarm:${swarm.activeAgents}/${swarm.maxAgents} Ctx:${system.contextPct}% Int:${system.intelligencePct}%`;
2737
+ const ctxDisplay = system.contextPct === null ? '--' : `${system.contextPct}%`;
2738
+ const line = `DDD:${progress.domainsCompleted}/${progress.totalDomains} CVE:${security.cvesFixed}/${security.totalCves} Swarm:${swarm.activeAgents}/${swarm.maxAgents} Ctx:${ctxDisplay} Int:${system.intelligencePct}%`;
2733
2739
  output.writeln(line);
2734
2740
  return { success: true, data: statusData };
2735
2741
  }
@@ -121,6 +121,7 @@ const DEFAULT_CONFIG = {
121
121
  show_model: true,
122
122
  show_session: true,
123
123
  show_intelligence: true,
124
+ show_context: true,
124
125
  show_swarm: true,
125
126
  show_hooks: true,
126
127
  show_mcp: true,
@@ -361,6 +362,7 @@ function mergeConfig(raw, root) {
361
362
  show_model: raw.status_line?.show_model ?? raw.statusLine?.showModel ?? DEFAULT_CONFIG.status_line.show_model,
362
363
  show_session: raw.status_line?.show_session ?? raw.statusLine?.showSession ?? DEFAULT_CONFIG.status_line.show_session,
363
364
  show_intelligence: raw.status_line?.show_intelligence ?? raw.statusLine?.showIntelligence ?? DEFAULT_CONFIG.status_line.show_intelligence,
365
+ show_context: raw.status_line?.show_context ?? raw.statusLine?.showContext ?? DEFAULT_CONFIG.status_line.show_context,
364
366
  show_swarm: raw.status_line?.show_swarm ?? raw.statusLine?.showSwarm ?? DEFAULT_CONFIG.status_line.show_swarm,
365
367
  show_hooks: raw.status_line?.show_hooks ?? raw.statusLine?.showHooks ?? DEFAULT_CONFIG.status_line.show_hooks,
366
368
  show_mcp: raw.status_line?.show_mcp ?? raw.statusLine?.showMcp ?? DEFAULT_CONFIG.status_line.show_mcp,
@@ -608,6 +610,7 @@ status_line:
608
610
  show_model: true # Current model name
609
611
  show_session: true # Session duration
610
612
  show_intelligence: true # Intelligence % indicator
613
+ show_context: true # Context-window % used (from Claude Code's stdin payload)
611
614
  show_swarm: true # Active swarm agents count
612
615
  show_hooks: true # Enabled hooks count
613
616
  show_mcp: true # MCP server count
@@ -133,13 +133,19 @@ export class StatuslineGenerator {
133
133
  // Memory color
134
134
  const memoryColor = data.system.memoryMB > 0 ? c.brightCyan : c.dim;
135
135
  const memoryDisplay = data.system.memoryMB > 0 ? `${data.system.memoryMB}MB` : '--';
136
- // Context color (lower is better)
137
- let contextColor = c.brightGreen;
138
- if (data.system.contextPct >= 75)
139
- contextColor = c.brightRed;
140
- else if (data.system.contextPct >= 50)
141
- contextColor = c.brightYellow;
142
- const contextDisplay = String(data.system.contextPct).padStart(3);
136
+ // Context color (lower is better). An unknown value renders dim `--`, never a
137
+ // number: a threshold-coloured 0% reads as a real measurement (#1453).
138
+ const contextPct = data.system.contextPct;
139
+ let contextColor = c.dim;
140
+ // Unit lives in the string (as with memoryDisplay above) so the unknown case
141
+ // reads `--` rather than `--%`.
142
+ let contextDisplay = ' --';
143
+ if (contextPct !== null) {
144
+ contextColor = contextPct >= 75 ? c.brightRed
145
+ : contextPct >= 50 ? c.brightYellow
146
+ : c.brightGreen;
147
+ contextDisplay = `${String(contextPct).padStart(3)}%`;
148
+ }
143
149
  // Intelligence color
144
150
  let intelColor = c.dim;
145
151
  if (data.system.intelligencePct >= 75)
@@ -155,7 +161,7 @@ export class StatuslineGenerator {
155
161
  `${subAgentColor}👥 ${data.system.subAgents}${c.reset} ` +
156
162
  `${securityIcon} ${securityColor}CVE ${data.security.cvesFixed}${c.reset}/${c.brightWhite}${data.security.totalCves}${c.reset} ` +
157
163
  `${memoryColor}💾 ${memoryDisplay}${c.reset} ` +
158
- `${contextColor}📂 ${contextDisplay}%${c.reset} ` +
164
+ `${contextColor}📂 ${contextDisplay}${c.reset} ` +
159
165
  `${intelColor}🧠 ${intelDisplay}%${c.reset}`);
160
166
  // Line 3: Architecture status
161
167
  const dddColor = data.v3Progress.dddProgress >= 50 ? c.brightGreen :
@@ -495,7 +501,7 @@ export class StatuslineGenerator {
495
501
  }
496
502
  return {
497
503
  memoryMB,
498
- contextPct: 0, // Requires Claude Code input
504
+ contextPct: null, // Unknowable here: needs Claude Code's stdin payload (#1453)
499
505
  intelligencePct,
500
506
  subAgents,
501
507
  };
@@ -21,15 +21,19 @@ const LEGACY_MARKER_ENDS = [
21
21
  ];
22
22
  /**
23
23
  * The single moflo section injected into CLAUDE.md.
24
- * ~22 lines. Points to moflo-core-guidance.md for everything else.
24
+ *
25
+ * This lands in EVERY consumer's CLAUDE.md and is read on every prompt, so it
26
+ * is a running token cost, not a doc. Keep it terse: state the rule, then point
27
+ * at moflo-core-guidance.md for the detail. Prefer cutting words over adding a
28
+ * line — anything needing a paragraph belongs in guidance, not here.
25
29
  */
26
30
  function mofloSection() {
27
31
  return `${MARKER_START}
28
32
  ## MoFlo — AI Agent Orchestration
29
33
 
30
- ### FIRST ACTION ON EVERY PROMPT: Search Memory
34
+ ### FIRST ACTION ON EVERY PROMPT — AND EVERY TOPIC CHANGE: Search Memory
31
35
 
32
- Your first tool call MUST be \`mcp__moflo__memory_search\` — before any Glob/Grep/Read. Pick the namespace by question shape: \`code-map\` for "where is symbol X defined", \`tests\` for "what tests cover Y", \`patterns\` for "what's our pattern for Z", \`guidance\` for project rules, \`learnings\` for "did we hit this before". Pivot on the bare symbol/keyword (not a natural-language question), and trust similarity ≥ 0.80 as a confident hit. When the user says "remember this", call \`mcp__moflo__memory_store\` with namespace \`learnings\`.
36
+ Your first tool call MUST be \`mcp__moflo__memory_search\` — before any Glob/Grep/Read or read-like Bash (\`cat\`, \`grep\`, \`node -e\`). **Search again on every new subject, mid-prompt** — new symbol, area, subsystem, or sub-question. A long task is many searches, not one. Pick the namespace by question shape: \`code-map\` for "where is symbol X defined", \`tests\` for "what tests cover Y", \`patterns\` for "what's our pattern for Z", \`guidance\` for project rules, \`learnings\` for "did we hit this before". Pivot on the bare symbol/keyword (not a natural-language question), and trust similarity ≥ 0.80 as a confident hit. When the user says "remember this", call \`mcp__moflo__memory_store\` with namespace \`learnings\`.
33
37
 
34
38
  ### Traverse chunks, don't bulk-retrieve
35
39
 
@@ -37,8 +41,9 @@ Search results carry a compact \`navigation\` crumb (parentDoc, prev/next, chunk
37
41
 
38
42
  ### Gates
39
43
 
40
- - **Blocking** (hook exits non-zero): memory search before Glob/Grep/guidance Read; tests + \`/flo-simplify\` + learnings + \`/verify\` before \`gh pr create\`; \`swarm_init\`/\`hive-mind_init\` before Agent under \`/fl -s|-h\`.
44
+ - **Blocking** (hook exits non-zero): memory search before Glob/Grep/guidance Read and before read-like Bash/PowerShell commands; tests + \`/flo-simplify\` + learnings + \`/verify\` before \`gh pr create\`; \`swarm_init\`/\`hive-mind_init\` before Agent under \`/fl -s|-h\`.
41
45
  - **Advisory** (reminder only): \`TaskCreate\` before spawning the Agent tool, entries in ICON+[Role] format — see \`.claude/guidance/moflo-task-icons.md\`.
46
+ - **Not enforced**: re-searching after a mid-prompt topic change — no hook sees the pivot. Do it anyway.
42
47
 
43
48
  ### Tools
44
49