plumbbob 0.8.18 → 0.9.1
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/.claude-plugin/plugin.json +1 -1
- package/README.md +50 -44
- package/dist/cli-core.js +35 -15
- package/dist/lib/agents.js +230 -112
- package/dist/lib/buildlog.js +50 -30
- package/dist/lib/buildlogsync.js +21 -12
- package/dist/lib/check.js +69 -35
- package/dist/lib/commitmsg.js +53 -32
- package/dist/lib/git.js +86 -31
- package/dist/lib/intent.js +91 -31
- package/dist/lib/latch.js +70 -39
- package/dist/lib/orient.js +78 -39
- package/dist/lib/plugins.js +11 -8
- package/dist/lib/settings.js +65 -32
- package/dist/lib/sidecar.js +320 -150
- package/dist/lib/templates.js +17 -6
- package/dist/verbs/agent.js +116 -55
- package/dist/verbs/build.js +27 -12
- package/dist/verbs/check.js +33 -7
- package/dist/verbs/checkpoint.js +153 -78
- package/dist/verbs/doctor.js +192 -95
- package/dist/verbs/finish.js +86 -45
- package/dist/verbs/handoff.js +56 -20
- package/dist/verbs/init.js +37 -12
- package/dist/verbs/park.js +14 -3
- package/dist/verbs/revert.js +64 -24
- package/dist/verbs/spike.js +89 -30
- package/dist/verbs/start.js +72 -38
- package/dist/verbs/status.js +44 -17
- package/dist/verbs/turn.js +88 -45
- package/dist/verbs/use.js +20 -9
- package/package.json +1 -1
- package/skills/{pb-build → build}/SKILL.md +25 -25
- package/skills/{pb-doctor → doctor}/SKILL.md +1 -1
- package/skills/{pb-finish → finish}/SKILL.md +3 -3
- package/skills/{pb-harvest → harvest}/SKILL.md +4 -4
- package/skills/{pb-park → park}/SKILL.md +2 -2
- package/skills/{pb-plan → plan}/SKILL.md +11 -11
- package/skills/{pb-refine → refine}/SKILL.md +7 -7
- package/skills/{pb-revert → revert}/SKILL.md +2 -2
- package/skills/{pb-spike → spike}/SKILL.md +4 -4
- package/skills/{pb-status → status}/SKILL.md +3 -3
- package/skills/{pb-step → step}/SKILL.md +9 -9
- package/skills/{pb-verify → verify}/SKILL.md +8 -8
- package/templates/build-log.md +7 -7
- package/templates/intent.md +8 -8
- package/templates/spike-report.md +1 -1
package/dist/lib/orient.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
//
|
|
2
|
-
// dashboard
|
|
3
|
-
// best-effort
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// The orientation model behind `status`: parse a live build session into the
|
|
2
|
+
// where-am-I dashboard — title, phase, step list, last checkpoint, counts, and
|
|
3
|
+
// one suggested next move. Pure and best-effort by design: it takes raw file
|
|
4
|
+
// contents (no fs), and a malformed document degrades to fewer fields rather
|
|
5
|
+
// than throwing.
|
|
6
6
|
import { parseStepSeam } from "./intent.js";
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* The lines of a named `## Section`, from its heading to the next `## ` (or EOF).
|
|
9
|
+
*/
|
|
8
10
|
function sectionLines(content, heading) {
|
|
9
11
|
const lines = content.split('\n');
|
|
10
12
|
const start = lines.findIndex((l) => l.trim() === heading);
|
|
@@ -17,6 +19,9 @@ function sectionLines(content, heading) {
|
|
|
17
19
|
}
|
|
18
20
|
return lines.slice(start + 1, end);
|
|
19
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* The build title — the first `# ` heading in intent.md, or null when absent.
|
|
24
|
+
*/
|
|
20
25
|
export function parseTitle(intent) {
|
|
21
26
|
for (const line of intent.split('\n')) {
|
|
22
27
|
const m = /^#\s+(.+?)\s*$/.exec(line);
|
|
@@ -26,9 +31,14 @@ export function parseTitle(intent) {
|
|
|
26
31
|
}
|
|
27
32
|
return null;
|
|
28
33
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Parse the steps under `## Steps`: `N. [ |x] Title — **done when:** ...`.
|
|
36
|
+
*
|
|
37
|
+
* The title is the text up to the first em dash; `planned` is true when the
|
|
38
|
+
* step's block carries a `done when` criterion. Only `## Steps` is machine-read
|
|
39
|
+
* as the build plan — narrative roadmap prose lives in its own section and
|
|
40
|
+
* never lands here.
|
|
41
|
+
*/
|
|
32
42
|
export function parseSteps(intent) {
|
|
33
43
|
const lines = sectionLines(intent, '## Steps');
|
|
34
44
|
const starts = [];
|
|
@@ -54,9 +64,12 @@ export function parseSteps(intent) {
|
|
|
54
64
|
return { n: s.n, done: s.done, title: s.title, planned: /done when/i.test(block), doneWhen, model };
|
|
55
65
|
});
|
|
56
66
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Flip step N's `[ ]` checkbox to `[x]` within the `## Steps` section.
|
|
69
|
+
*
|
|
70
|
+
* Mechanical bookkeeping for `checkpoint`, so `status` reflects a checkpointed
|
|
71
|
+
* step. A no-op if the step is absent or already done.
|
|
72
|
+
*/
|
|
60
73
|
export function markStepDone(intent, n) {
|
|
61
74
|
let inSteps = false;
|
|
62
75
|
return intent
|
|
@@ -73,18 +86,31 @@ export function markStepDone(intent, n) {
|
|
|
73
86
|
})
|
|
74
87
|
.join('\n');
|
|
75
88
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Count the questions still open: `- Q\d+:` openers under `## Open questions`
|
|
91
|
+
* that do not say "resolved".
|
|
92
|
+
*
|
|
93
|
+
* The opener may carry a slug-at-birth gloss — `- Q2 (some-slug): ...` — which
|
|
94
|
+
* the count reads through; sub-lines (`*plain:*`/`*lean:*`) never match.
|
|
95
|
+
*/
|
|
79
96
|
export function parseOpenQuestions(intent) {
|
|
80
97
|
return sectionLines(intent, '## Open questions').filter((l) => /^- Q\d+(?: \([^)]+\))?:/.test(l.trim()) && !/resolved/i.test(l)).length;
|
|
81
98
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Count the open parked items: `- [ ]` lines under `## Park list`.
|
|
101
|
+
*
|
|
102
|
+
* A parked item is a mid-build idea the `park` verb appends as a flat checklist
|
|
103
|
+
* line for later triage; `/plumbbob:harvest` flips a triaged one to `- [x]` and it
|
|
104
|
+
* stops counting. The `(none yet)` placeholder and the blockquote instructions
|
|
105
|
+
* never match.
|
|
106
|
+
*/
|
|
85
107
|
export function parseParked(buildLog) {
|
|
86
108
|
return sectionLines(buildLog, '## Park list').filter((l) => /^-\s+\[ \]\s+\S/.test(l.trim())).length;
|
|
87
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* The last `step N <sha>` line in the checkpoints ledger, or null when no step
|
|
112
|
+
* has landed yet.
|
|
113
|
+
*/
|
|
88
114
|
export function parseLastCheckpoint(checkpoints) {
|
|
89
115
|
let last = null;
|
|
90
116
|
for (const line of checkpoints.split('\n')) {
|
|
@@ -95,12 +121,15 @@ export function parseLastCheckpoint(checkpoints) {
|
|
|
95
121
|
}
|
|
96
122
|
return last;
|
|
97
123
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
124
|
+
/**
|
|
125
|
+
* The SHA of the last ledger line of ANY kind — baseline, plan, or step.
|
|
126
|
+
*
|
|
127
|
+
* This is the reconciliation anchor for the out-of-band commit count: a commit
|
|
128
|
+
* that lands after the plan but before the first step checkpoint is exactly the
|
|
129
|
+
* window the reconciliation line exists for, so anchoring on step lines alone
|
|
130
|
+
* would leave it invisible. `parseLastCheckpoint` stays step-only — it feeds
|
|
131
|
+
* the "last checkpoint step N" display, where baseline/plan would be noise.
|
|
132
|
+
*/
|
|
104
133
|
export function lastLedgerSha(checkpoints) {
|
|
105
134
|
let last = null;
|
|
106
135
|
for (const line of checkpoints.split('\n')) {
|
|
@@ -111,32 +140,39 @@ export function lastLedgerSha(checkpoints) {
|
|
|
111
140
|
}
|
|
112
141
|
return last;
|
|
113
142
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
143
|
+
/**
|
|
144
|
+
* The single primary next move the dashboard suggests.
|
|
145
|
+
*
|
|
146
|
+
* It suggests; the dashboard prints the full list + counts so the human can
|
|
147
|
+
* always override. The phase is derived: a spike in progress and an in-flight
|
|
148
|
+
* step each have one obvious next move; otherwise you are at the boundary and
|
|
149
|
+
* the move follows from the steps.
|
|
150
|
+
*/
|
|
118
151
|
function nextMove(spiking, steps, inFlight, parked) {
|
|
119
152
|
if (spiking) {
|
|
120
153
|
return 'close the spike — `plumbbob spike done`';
|
|
121
154
|
}
|
|
122
155
|
if (inFlight !== null) {
|
|
123
|
-
return `finish step ${inFlight} — \`/
|
|
156
|
+
return `finish step ${inFlight} — \`/plumbbob:verify\` (or keep editing, then \`/plumbbob:verify\`)`;
|
|
124
157
|
}
|
|
125
158
|
// At the boundary (DESIGN): the move follows from the steps.
|
|
126
159
|
const nextUndone = steps.find((s) => !s.done);
|
|
127
160
|
if (nextUndone === undefined) {
|
|
128
161
|
if (steps.length === 0) {
|
|
129
|
-
return 'plan the first step — `/
|
|
162
|
+
return 'plan the first step — `/plumbbob:step`';
|
|
130
163
|
}
|
|
131
164
|
// Batch-default: the steps were planned up front, so finishing them usually
|
|
132
|
-
// means "finish up" — but `/
|
|
133
|
-
const harvest = parked > 0 ? `harvest ${parked} parked idea${parked === 1 ? '' : 's'} — \`/
|
|
134
|
-
return `${harvest}finish up — \`/
|
|
165
|
+
// means "finish up" — but `/plumbbob:step` can still add an increment if reality grew.
|
|
166
|
+
const harvest = parked > 0 ? `harvest ${parked} parked idea${parked === 1 ? '' : 's'} — \`/plumbbob:harvest\`; then ` : '';
|
|
167
|
+
return `${harvest}finish up — \`/plumbbob:finish\` (or \`/plumbbob:step\` to add another increment)`;
|
|
135
168
|
}
|
|
136
169
|
return nextUndone.planned
|
|
137
|
-
? `build step ${nextUndone.n} — \`/
|
|
138
|
-
: `plan step ${nextUndone.n} — \`/
|
|
170
|
+
? `build step ${nextUndone.n} — \`/plumbbob:build\` (or \`/plumbbob:step\` to revise it first)`
|
|
171
|
+
: `plan step ${nextUndone.n} — \`/plumbbob:step\``;
|
|
139
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* Assemble the full orientation from a build's raw documents and marker state.
|
|
175
|
+
*/
|
|
140
176
|
export function orient(input) {
|
|
141
177
|
const steps = parseSteps(input.intent);
|
|
142
178
|
const parked = parseParked(input.buildLog);
|
|
@@ -157,6 +193,9 @@ export function orient(input) {
|
|
|
157
193
|
outOfBand: input.outOfBand,
|
|
158
194
|
};
|
|
159
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* Render an Orientation as the plain-text dashboard `status` prints.
|
|
198
|
+
*/
|
|
160
199
|
export function formatOrientation(o) {
|
|
161
200
|
const doneCount = o.steps.filter((s) => s.done).length;
|
|
162
201
|
const nextUndone = o.steps.find((s) => !s.done);
|
|
@@ -167,7 +206,7 @@ export function formatOrientation(o) {
|
|
|
167
206
|
if (s !== nextUndone) {
|
|
168
207
|
return head;
|
|
169
208
|
}
|
|
170
|
-
// Surface the next step's detail so the human can review it (and `/
|
|
209
|
+
// Surface the next step's detail so the human can review it (and `/plumbbob:step`-
|
|
171
210
|
// revise) before building. Only what's present — a rough step shows neither.
|
|
172
211
|
const detail = [];
|
|
173
212
|
if (o.nextDoneWhen !== null) {
|
|
@@ -184,9 +223,9 @@ export function formatOrientation(o) {
|
|
|
184
223
|
const stepsBlock = o.steps.length === 0 ? ' (no steps planned yet)' : ` steps ${doneCount}/${o.steps.length} done\n${stepLines.join('\n')}`;
|
|
185
224
|
const cp = o.lastCheckpoint;
|
|
186
225
|
const cpLine = cp === null ? 'last checkpoint none yet' : `last checkpoint step ${cp.n} · ${cp.sha.slice(0, 7)}`;
|
|
187
|
-
// A neutral reconciliation note
|
|
188
|
-
// commits landed since the last checkpoint that plumbbob's ledger didn't
|
|
189
|
-
// Informational — the human commits freely
|
|
226
|
+
// A neutral reconciliation note, only when there is something to reconcile:
|
|
227
|
+
// commits landed since the last checkpoint that plumbbob's ledger didn't
|
|
228
|
+
// record. Informational — the human commits freely, so this never gates.
|
|
190
229
|
const receipts = o.outOfBand > 0
|
|
191
230
|
? [`${o.outOfBand} commit${o.outOfBand === 1 ? '' : 's'} since the last checkpoint landed outside plumbbob's ledger.`]
|
|
192
231
|
: [];
|
package/dist/lib/plugins.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
// Detect a marketplace-installed plumbbob plugin from Claude Code's plugin state.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// means "none installed".
|
|
2
|
+
// Keeps the skills-dir install (`plumbbob init`) from colliding with a marketplace
|
|
3
|
+
// install: two plugins both named `plumbbob` fight over the `/plumbbob:*` namespace
|
|
4
|
+
// and the skills can drop to flat names (`/status`). Read-only, node builtins
|
|
5
|
+
// only: a missing, unreadable, or malformed file means "none installed".
|
|
7
6
|
import { readFileSync } from 'node:fs';
|
|
8
7
|
import { join } from 'node:path';
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Marketplace plugin ids for plumbbob recorded in installed_plugins.json.
|
|
10
|
+
*
|
|
11
|
+
* Ids look like "plumbbob@robmclarty": a bare `plumbbob` or a
|
|
12
|
+
* `plumbbob@<marketplace>` id matches; `plumbbob-spike@x` and the like do not.
|
|
13
|
+
* Empty when none are installed, or the file is absent, unreadable, or malformed.
|
|
14
|
+
*/
|
|
12
15
|
export function marketplacePlumbbob(home) {
|
|
13
16
|
try {
|
|
14
17
|
const raw = readFileSync(join(home, '.claude', 'plugins', 'installed_plugins.json'), 'utf8');
|
package/dist/lib/settings.js
CHANGED
|
@@ -1,29 +1,42 @@
|
|
|
1
|
-
// The settings ladder
|
|
1
|
+
// The settings ladder: a resolved setting comes from, in priority order,
|
|
2
2
|
// 1. a CLI flag — passed in by the verb (undefined when absent)
|
|
3
|
-
// 2. settings.local.json — untracked personal overlay
|
|
3
|
+
// 2. settings.local.json — untracked personal overlay, per-worktree
|
|
4
4
|
// 3. settings.json — tracked project defaults
|
|
5
5
|
// 4. a built-in default — supplied by the caller
|
|
6
6
|
// The first defined rung wins. Both files are optional JSON; a missing or
|
|
7
7
|
// malformed file contributes nothing rather than throwing, so a broken personal
|
|
8
|
-
// overlay can never wedge the tool. Functional
|
|
8
|
+
// overlay can never wedge the tool. Functional and procedural, node builtins only.
|
|
9
9
|
//
|
|
10
|
-
// Known keys: `check` (string — the heavy gate,
|
|
11
|
-
// `auto` (boolean — a personal preference, so it
|
|
12
|
-
//
|
|
13
|
-
// a land
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
10
|
+
// Known keys: `check` (string — the command for the heavy verify gate, a project
|
|
11
|
+
// default in settings.json) and `auto` (boolean — a personal preference, so it
|
|
12
|
+
// belongs in settings.local.json). `auto` is NOT a checkpoint grant: the approval
|
|
13
|
+
// latch never reads it to allow a land — a model can write this file, and a grant
|
|
14
|
+
// it can forge is no grant — only to name it at the pause when set; self-approval
|
|
15
|
+
// comes solely from the human's literal `/build --auto` or a typed step range.
|
|
16
|
+
// The per-worktree active-build cursor is NOT here — it lives in the STATE file
|
|
17
|
+
// (see sidecar.ts), which keeps this overlay a human-authored file rather than
|
|
18
|
+
// tool-managed state.
|
|
18
19
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
19
20
|
import { dirname, join } from 'node:path';
|
|
20
|
-
const DIRNAME = '.plumbbob';
|
|
21
|
+
const DIRNAME = '.plumbbob'; // the sidecar folder name at the repo root
|
|
22
|
+
/**
|
|
23
|
+
* The tracked project-defaults file: `.plumbbob/settings.json`.
|
|
24
|
+
*/
|
|
21
25
|
export function settingsPath(root) {
|
|
22
26
|
return join(root, DIRNAME, 'settings.json');
|
|
23
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* The untracked personal overlay: `.plumbbob/settings.local.json`.
|
|
30
|
+
*/
|
|
24
31
|
export function localSettingsPath(root) {
|
|
25
32
|
return join(root, DIRNAME, 'settings.local.json');
|
|
26
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Read one settings file as a plain object.
|
|
36
|
+
*
|
|
37
|
+
* A missing, malformed, or non-object file yields {} — an empty rung that
|
|
38
|
+
* contributes nothing rather than wedging the tool.
|
|
39
|
+
*/
|
|
27
40
|
function readSettings(path) {
|
|
28
41
|
try {
|
|
29
42
|
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
@@ -33,8 +46,12 @@ function readSettings(path) {
|
|
|
33
46
|
return {};
|
|
34
47
|
}
|
|
35
48
|
}
|
|
36
|
-
|
|
37
|
-
|
|
49
|
+
/**
|
|
50
|
+
* The raw ladder: flag → local overlay → project defaults → undefined.
|
|
51
|
+
*
|
|
52
|
+
* The typed helpers below apply the built-in default and reject wrong-typed
|
|
53
|
+
* rungs.
|
|
54
|
+
*/
|
|
38
55
|
function resolveSetting(root, key, flag) {
|
|
39
56
|
if (flag !== undefined)
|
|
40
57
|
return flag;
|
|
@@ -43,40 +60,56 @@ function resolveSetting(root, key, flag) {
|
|
|
43
60
|
return local;
|
|
44
61
|
return readSettings(settingsPath(root))[key];
|
|
45
62
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Resolve a string setting (e.g. `check`).
|
|
65
|
+
*
|
|
66
|
+
* A missing rung, or one holding a non-string / blank value, yields the
|
|
67
|
+
* caller's fallback rather than gating on garbage.
|
|
68
|
+
*/
|
|
49
69
|
export function resolveString(root, key, fallback, flag) {
|
|
50
70
|
const value = resolveSetting(root, key, flag);
|
|
51
71
|
return typeof value === 'string' && value.trim().length > 0 ? value : fallback;
|
|
52
72
|
}
|
|
53
|
-
|
|
54
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Resolve a boolean setting (e.g. `auto`). A missing or non-boolean rung
|
|
75
|
+
* yields the caller's fallback.
|
|
76
|
+
*/
|
|
55
77
|
export function resolveBoolean(root, key, fallback, flag) {
|
|
56
78
|
const value = resolveSetting(root, key, flag);
|
|
57
79
|
return typeof value === 'boolean' ? value : fallback;
|
|
58
80
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Resolve a non-negative integer setting (e.g. `agentTimeout`).
|
|
83
|
+
*
|
|
84
|
+
* A missing rung, or one holding a non-finite / negative / non-integer number,
|
|
85
|
+
* yields the caller's fallback rather than a garbage timeout — 0 means "no
|
|
86
|
+
* timeout", so a broken value must never silently become one.
|
|
87
|
+
*/
|
|
63
88
|
export function resolveNumber(root, key, fallback, flag) {
|
|
64
89
|
const value = resolveSetting(root, key, flag);
|
|
65
90
|
return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : fallback;
|
|
66
91
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Resolve an object-valued setting (e.g. the `agents` slot-binding defaults)
|
|
94
|
+
* across the same ladder.
|
|
95
|
+
*
|
|
96
|
+
* The first defined, object-typed rung wins; a missing rung, or one holding a
|
|
97
|
+
* non-object / array / scalar, yields {} rather than garbage — mirrors the
|
|
98
|
+
* string/boolean/number resolvers above. No flag rung: the caller (`agent
|
|
99
|
+
* run`) merges these under the per-build harness bindings, not over a flag.
|
|
100
|
+
*/
|
|
72
101
|
export function resolveRecord(root, key) {
|
|
73
102
|
const value = resolveSetting(root, key, undefined);
|
|
74
103
|
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : {};
|
|
75
104
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
105
|
+
/**
|
|
106
|
+
* Merge one key into settings.local.json, preserving the other keys and
|
|
107
|
+
* creating the file when absent.
|
|
108
|
+
*
|
|
109
|
+
* Pretty-printed so the overlay stays hand-editable. A malformed existing file
|
|
110
|
+
* contributes nothing (readSettings yields {}) and is overwritten with a clean
|
|
111
|
+
* object rather than throwing.
|
|
112
|
+
*/
|
|
80
113
|
export function setLocalSetting(root, key, value) {
|
|
81
114
|
const path = localSettingsPath(root);
|
|
82
115
|
mkdirSync(dirname(path), { recursive: true });
|