@dev-loops/core 1.0.0-rc.6 → 1.0.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 (44) hide show
  1. package/package.json +8 -1
  2. package/src/analysis/change-classifier.mjs +10 -0
  3. package/src/analysis/diff-analyzer.mjs +68 -1
  4. package/src/claude/hook-decisions.mjs +36 -4
  5. package/src/cli/primitives.mjs +30 -1
  6. package/src/config/config.mjs +254 -13
  7. package/src/config/extension-defaults.yaml +34 -1
  8. package/src/github/comment-id-guard.mjs +97 -9
  9. package/src/github/copilot-helpers.mjs +114 -5
  10. package/src/github/gh.mjs +94 -0
  11. package/src/github/issue-ops.mjs +7 -0
  12. package/src/loop/agent-stall.mjs +4 -2
  13. package/src/loop/commit-msg-guard.mjs +168 -0
  14. package/src/loop/copilot-loop-iterations.mjs +2 -1
  15. package/src/loop/default-branch-guard.mjs +34 -1
  16. package/src/loop/gate-carry-forward.mjs +19 -6
  17. package/src/loop/gate-fanin.mjs +190 -29
  18. package/src/loop/handoff-envelope.mjs +12 -19
  19. package/src/loop/issue-refinement-artifact.mjs +186 -42
  20. package/src/loop/lifecycle-state.mjs +21 -2
  21. package/src/loop/main-checkout-ff.mjs +34 -0
  22. package/src/loop/markdown-sections.mjs +40 -0
  23. package/src/loop/normalize.mjs +7 -0
  24. package/src/loop/plan-file-promote-contract.mjs +14 -1
  25. package/src/loop/plan-file-refine-contract.mjs +92 -8
  26. package/src/loop/policy-constants.mjs +9 -0
  27. package/src/loop/pr-gate-coordination.mjs +65 -12
  28. package/src/loop/public-dev-loop-routing.mjs +11 -15
  29. package/src/loop/queue-board-sync.mjs +1 -26
  30. package/src/loop/queue-driver.mjs +14 -1
  31. package/src/loop/refinement-grill-state.mjs +3 -5
  32. package/src/loop/retrospective-checkpoint.mjs +59 -1
  33. package/src/loop/review-dispatch-plan.mjs +448 -9
  34. package/src/loop/reviewer-loop-state.mjs +8 -13
  35. package/src/loop/run-post-merge-actions.mjs +148 -0
  36. package/src/loop/size-budget-merge-gate.mjs +121 -0
  37. package/src/loop/tracker-pr-state.mjs +5 -15
  38. package/src/loop/ui-designer-review-scoping.mjs +171 -0
  39. package/src/loop/ui-review-drive.mjs +3 -1
  40. package/src/loop/ui-review-report.mjs +2 -5
  41. package/src/loop/ui-review-teardown.mjs +3 -1
  42. package/src/projects/list-queue-items.mjs +1 -27
  43. package/src/projects/move-queue-item.mjs +2 -28
  44. package/src/security/secret-scan.mjs +330 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev-loops/core",
3
- "version": "1.0.0-rc.6",
3
+ "version": "1.0.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -22,6 +22,7 @@
22
22
  "./debt/signal": "./src/debt/debt-signal.mjs",
23
23
  "./github/copilot-helpers": "./src/github/copilot-helpers.mjs",
24
24
  "./github/comment-id-guard": "./src/github/comment-id-guard.mjs",
25
+ "./github/gh": "./src/github/gh.mjs",
25
26
  "./github/issue-ops": "./src/github/issue-ops.mjs",
26
27
  "./github/ownership-helpers": "./src/github/ownership-helpers.mjs",
27
28
  "./github/repo-slug": "./src/github/repo-slug.mjs",
@@ -37,6 +38,7 @@
37
38
  "./loop/gate-fanin": "./src/loop/gate-fanin.mjs",
38
39
  "./loop/handoff-envelope": "./src/loop/handoff-envelope.mjs",
39
40
  "./loop/lifecycle-state": "./src/loop/lifecycle-state.mjs",
41
+ "./loop/markdown-sections": "./src/loop/markdown-sections.mjs",
40
42
  "./loop/issue-refinement-artifact": "./src/loop/issue-refinement-artifact.mjs",
41
43
  "./loop/phase-files": "./src/loop/phase-files.mjs",
42
44
  "./loop/policy-constants": "./src/loop/policy-constants.mjs",
@@ -51,17 +53,20 @@
51
53
  "./loop/refinement-grill-state": "./src/loop/refinement-grill-state.mjs",
52
54
  "./loop/queue-board-ordering": "./src/loop/queue-board-ordering.mjs",
53
55
  "./loop/default-branch-guard": "./src/loop/default-branch-guard.mjs",
56
+ "./loop/commit-msg-guard": "./src/loop/commit-msg-guard.mjs",
54
57
  "./loop/queue-board-sync": "./src/loop/queue-board-sync.mjs",
55
58
  "./loop/queue-driver": "./src/loop/queue-driver.mjs",
56
59
  "./loop/queue-membership": "./src/loop/queue-membership.mjs",
57
60
  "./loop/queue-parallel": "./src/loop/queue-parallel.mjs",
58
61
  "./loop/queue-state": "./src/loop/queue-state.mjs",
62
+ "./loop/size-budget-merge-gate": "./src/loop/size-budget-merge-gate.mjs",
59
63
  "./loop/cache-telemetry-evidence": "./src/loop/cache-telemetry-evidence.mjs",
60
64
  "./loop/primer-evidence": "./src/loop/primer-evidence.mjs",
61
65
  "./loop/review-dispatch-plan": "./src/loop/review-dispatch-plan.mjs",
62
66
  "./loop/reviewer-loop-state": "./src/loop/reviewer-loop-state.mjs",
63
67
  "./loop/run-context": "./src/loop/run-context.mjs",
64
68
  "./loop/run-inspection": "./src/loop/run-inspection.mjs",
69
+ "./loop/run-post-merge-actions": "./src/loop/run-post-merge-actions.mjs",
65
70
  "./loop/spike-exit-contract": "./src/loop/spike-exit-contract.mjs",
66
71
  "./loop/spike-intake-contract": "./src/loop/spike-intake-contract.mjs",
67
72
  "./loop/steering": "./src/loop/steering.mjs",
@@ -73,6 +78,7 @@
73
78
  "./loop/ui-review-diagnose": "./src/loop/ui-review-diagnose.mjs",
74
79
  "./loop/ui-review-report": "./src/loop/ui-review-report.mjs",
75
80
  "./loop/ui-review-teardown": "./src/loop/ui-review-teardown.mjs",
81
+ "./security/secret-scan": "./src/security/secret-scan.mjs",
76
82
  "./projects/list-queue-items": "./src/projects/list-queue-items.mjs",
77
83
  "./projects/move-queue-item": "./src/projects/move-queue-item.mjs",
78
84
  "./projects/resolve-project": "./src/projects/resolve-project.mjs",
@@ -80,6 +86,7 @@
80
86
  "./tracker": "./src/tracker/index.mjs",
81
87
  "./loop/worktree-guard": "./src/loop/worktree-guard.mjs",
82
88
  "./loop/main-checkout-ff": "./src/loop/main-checkout-ff.mjs",
89
+ "./loop/normalize": "./src/loop/normalize.mjs",
83
90
  "./loop/tracker-first-loop-state": "./src/loop/tracker-first-loop-state.mjs"
84
91
  },
85
92
  "bin": {
@@ -22,6 +22,10 @@ export const ChangeCategory = Object.freeze({
22
22
  // child_process/shell exec, untrusted network fetch, destructive filesystem
23
23
  // ops / local-file upload). Triggers an up-front adversarial threat-model angle.
24
24
  SECURITY_SENSITIVE_SEAM: "SECURITY_SENSITIVE_SEAM",
25
+ // #1442: a changed file is on the prose surface (docs/articles/**,
26
+ // docs/presentations/**, README*, narrative docs/*.md) — ADR 0041 prose half.
27
+ // Triggers the required fail-closed `deslop` gate angle.
28
+ PROSE_PRESENT: "PROSE_PRESENT",
25
29
  });
26
30
 
27
31
  // ---------------------------------------------------------------------------
@@ -69,6 +73,12 @@ export const CATEGORY_ANGLE_MAP = {
69
73
  [ChangeCategory.SECURITY_SENSITIVE_SEAM]: [
70
74
  "threat-model", "input-validation", "scope", "correctness",
71
75
  ],
76
+ // #1442 (ADR 0041 prose half): a prose-surface path is present → run the
77
+ // required `deslop` gate angle. skills/docs/** is deliberately NOT prose, so
78
+ // a normative-contract-only docs diff never triggers deslop.
79
+ [ChangeCategory.PROSE_PRESENT]: [
80
+ "deslop",
81
+ ],
72
82
  };
73
83
 
74
84
  /**
@@ -21,6 +21,44 @@
21
21
  // fails closed toward a full re-review, which is what a version bump needs.
22
22
  const DOTFILE_CONFIG_BASENAMES = new Set([".devloops"]);
23
23
 
24
+ // #1442 (ADR 0041 prose half): the prose surface that triggers the required
25
+ // `deslop` gate angle. skills/docs/** is deliberately excluded — those are
26
+ // normative contracts (owned by the contract style guide + contradiction
27
+ // lens), not prose, and deslop's contrast-cutting must not fight RFC-2119
28
+ // modality.
29
+ const PROSE_PATH_RE = /^docs\/(articles|presentations)\//;
30
+ const NARRATIVE_DOC_RE = /^docs\/[^/]+\.(md|markdown)$/;
31
+ // #1442 review finding: the skills/docs/** exemption must hold for the README
32
+ // rule too. PROSE_PATH_RE / NARRATIVE_DOC_RE are anchored to `docs/` so
33
+ // skills/docs files never match them; the README basename rule is the ONLY
34
+ // path by which a skills/docs file could be classified prose (e.g. a future
35
+ // `skills/docs/README-*.md`). Carve the normative-contract subtree out
36
+ // explicitly so a README-named contract never arms deslop.
37
+ const SKILLS_DOCS_EXEMPT_RE = /^(skills\/docs|\x2eclaude\/skills\/docs)\//;
38
+
39
+ /**
40
+ * Whether a file path is on the prose surface (#1442): docs/articles/**,
41
+ * docs/presentations/**, README*, or a narrative direct-child docs/*.md.
42
+ * skills/docs/** (and .claude/skills/docs/**) is exempt (normative
43
+ * contracts, not prose) across ALL rules, including the README basename rule.
44
+ *
45
+ * @param {string} filePath
46
+ * @returns {boolean}
47
+ */
48
+ export function isProsePath(filePath) {
49
+ // #1442 review finding (input-validation): exported trust-boundary guard.
50
+ // Fail closed (false) on a non-string/empty argument rather than crashing in
51
+ // normalizeSep(filePath).replaceAll(...).
52
+ if (typeof filePath !== "string" || filePath.length === 0) return false;
53
+ const fp = normalizeSep(filePath);
54
+ if (SKILLS_DOCS_EXEMPT_RE.test(fp)) return false;
55
+ if (PROSE_PATH_RE.test(fp)) return true;
56
+ if (NARRATIVE_DOC_RE.test(fp)) return true;
57
+ const base = fp.split("/").pop() ?? "";
58
+ if (base.startsWith("README")) return true;
59
+ return false;
60
+ }
61
+
24
62
  /**
25
63
  * @typedef {object} T0Result
26
64
  * @property {string[]} files — flat file paths
@@ -56,6 +94,13 @@ export function analyzeT0(nameStatusOutput) {
56
94
  const extensions = new Set();
57
95
  const directories = new Set();
58
96
  let renameCount = 0;
97
+ // #1442 review finding: prose arming must be scoped to content-carrying
98
+ // rows. A pure deletion (`D` name-status row) has no prose content for the
99
+ // deslop reviewer to strip — arming deslop on it over-selects and re-runs
100
+ // the fan-out over a content-free delta. Added/modified/renamed-dest rows
101
+ // (A / M / M? / R-dest) carry content and still arm prose. Rename rows emit
102
+ // `R<score>\told\tnew` (rawPath = parts[2], the new content-bearing path).
103
+ let prosePresent = false;
59
104
 
60
105
  for (const line of lines) {
61
106
  const parts = line.split("\t");
@@ -73,6 +118,16 @@ export function analyzeT0(nameStatusOutput) {
73
118
  if (dir) directories.add(dir);
74
119
 
75
120
  if (status.startsWith("R")) renameCount++;
121
+ // #1442: a content-carrying prose file arms PROSE_PRESENT → deslop. Pure
122
+ // deletions (`D`) are excluded (no prose content to strip, avoids noise);
123
+ // a pure `R100` rename (git's 100% similarity score — no content changed)
124
+ // is likewise content-free and must not arm deslop. Renames with a score
125
+ // below 100 (`R<score>\told\tnew`, rawPath = the new content-bearing path)
126
+ // carry content and still arm prose.
127
+ if (prosePresent) continue;
128
+ if (status === "D" || status.startsWith("D")) continue;
129
+ if (status.startsWith("R") && status === "R100") continue;
130
+ if (isProsePath(path)) prosePresent = true;
76
131
  }
77
132
 
78
133
  const renameOnly = lines.length > 0 && renameCount === lines.length;
@@ -87,6 +142,7 @@ export function analyzeT0(nameStatusOutput) {
87
142
  directories: [...directories].sort(),
88
143
  renameOnly,
89
144
  allDocs,
145
+ prosePresent,
90
146
  };
91
147
  }
92
148
 
@@ -372,6 +428,7 @@ function t0FileCategories(t0) {
372
428
  const categories = [];
373
429
  if (t0.renameOnly) categories.push("RENAME_ONLY");
374
430
  if (t0.allDocs) categories.push("DOCS_ONLY");
431
+ if (t0.prosePresent) categories.push("PROSE_PRESENT");
375
432
  if (t0.files.every((f) => classifyFile(f) === "config")) categories.push("CONFIG_ONLY");
376
433
  if (t0.files.every((f) => classifyFile(f) === "test")) categories.push("TEST_ONLY");
377
434
  if (t0.files.every((f) => classifyFile(f) === "ci")) categories.push("CI_ONLY");
@@ -392,6 +449,7 @@ function t0PresentSurfaceCategories(t0) {
392
449
  const categories = [];
393
450
  const cats = new Set(t0.files.map(classifyFile));
394
451
  if (cats.has("docs")) categories.push("DOCS_ONLY");
452
+ if (t0.prosePresent) categories.push("PROSE_PRESENT");
395
453
  if (cats.has("config")) categories.push("CONFIG_ONLY");
396
454
  if (cats.has("test")) categories.push("TEST_ONLY");
397
455
  if (cats.has("ci")) categories.push("CI_ONLY");
@@ -445,8 +503,17 @@ export function analyzeDiff({ nameStatusOutput, diffOutput }) {
445
503
  // When t1 is null (unambiguous diff), infer categories from t0
446
504
  // so dynamic angle resolution can narrow for config-only / test-only etc.
447
505
  if (!t1) {
506
+ // #1442 Copilot finding: a genuinely MIXED diff whose T1 never ran (no
507
+ // diffOutput) must NOT get a T0-only PROSE_PRESENT category. Non-empty
508
+ // categories set ambiguous=false, so a mixed code+prose diff would
509
+ // under-select to just deslop + always-include and drop the code-review
510
+ // core. T0-only inference is only safe for unambiguous diffs (docs-only /
511
+ // single surface); a mixed diff without hunk content is unclassifiable, so
512
+ // return empty categories and let resolveDynamicAngles fall back to the
513
+ // full angle set (fail closed).
514
+ const changeCategories = t0Ambiguous ? [] : inferCategoriesFromT0(t0);
448
515
  t1 = {
449
- changeCategories: inferCategoriesFromT0(t0),
516
+ changeCategories,
450
517
  hunkCount: 0,
451
518
  lineStats: { added: 0, deleted: 0 },
452
519
  };
@@ -383,6 +383,23 @@ export function decideWriteGuard({ filePath, isRepoMutation, enforce = false, en
383
383
  */
384
384
  export const DEVLOOPS_COMMIT_AUTH_PENDING_VAR = "DEVLOOPS_COMMIT_AUTH_PENDING";
385
385
 
386
+ /**
387
+ * Env var that exempts an orchestrator-owned-commit dispatch from the SubagentStop
388
+ * uncommitted-work guard (#1786).
389
+ *
390
+ * A "LOCAL EDITS ONLY: no commit" dispatch (e.g. the `developer`/`quality`/`docs` delegation
391
+ * pattern in `skills/local-implementation/SKILL.md` "Delegation contract") tells the subagent to
392
+ * make local edits and report changed files, leaving commit + push to the dispatching
393
+ * orchestrator once it consolidates results. Without an exemption, that subagent's own
394
+ * SubagentStop event still sees the dirty worktree it was told not to commit and deadlocks. The
395
+ * dispatcher sets `DEVLOOPS_ORCHESTRATOR_OWNS_COMMIT=1` for that dispatch to declare it owns the
396
+ * commit — same opt-in `DEVLOOPS_*` signal shape as `DEVLOOPS_COMMIT_AUTH_PENDING`, but distinct:
397
+ * this one exempts a non-interactive delegated dispatch whose commit responsibility sits with its
398
+ * caller, not an interactive session awaiting operator authorization. Left unset, an ordinary
399
+ * dispatch's commit-before-exit obligation stays enforced (fail closed by default).
400
+ */
401
+ export const DEVLOOPS_ORCHESTRATOR_OWNS_COMMIT_VAR = "DEVLOOPS_ORCHESTRATOR_OWNS_COMMIT";
402
+
386
403
  /**
387
404
  * Decide whether a SubagentStop must be blocked because the subagent's worktree has
388
405
  * uncommitted changes (#1619).
@@ -391,7 +408,8 @@ export const DEVLOOPS_COMMIT_AUTH_PENDING_VAR = "DEVLOOPS_COMMIT_AUTH_PENDING";
391
408
  * uncommitted changes in a worktree are destroyed with no warning. `LOCAL-COMMIT-BEFORE-EXIT`
392
409
  * existed only as prose. This decider makes it mechanical: refuse the subagent stop when the
393
410
  * cwd is under `tmp/worktrees/` and `git status --porcelain` is non-empty, unless the session
394
- * is an interactive one awaiting commit authorization (exempt). A clean worktree, a cwd
411
+ * is an interactive one awaiting commit authorization, or the dispatch is an explicit
412
+ * orchestrator-owned-commit exemption (#1786) (either exempt). A clean worktree, a cwd
395
413
  * outside `tmp/worktrees/`, and a git-error/empty-porcelain case all allow the stop.
396
414
  *
397
415
  * Pure and side-effect free. The hook script gathers `cwd` and the `git status --porcelain`
@@ -406,13 +424,16 @@ export const DEVLOOPS_COMMIT_AUTH_PENDING_VAR = "DEVLOOPS_COMMIT_AUTH_PENDING";
406
424
  * @param {boolean} [params.pendingCommitAuthorization] - True when the interactive session is
407
425
  * awaiting commit authorization (exempt) — derived by the hook script from the
408
426
  * `DEVLOOPS_COMMIT_AUTH_PENDING=1` opt-in env signal.
427
+ * @param {boolean} [params.orchestratorOwnsCommit] - True when this dispatch is an explicit
428
+ * orchestrator-owned-commit exemption (exempt) — derived by the hook script from the
429
+ * `DEVLOOPS_ORCHESTRATOR_OWNS_COMMIT=1` opt-in env signal.
409
430
  * @returns {HookDecision}
410
431
  */
411
- export function decideSubagentStopGuard({ cwd, porcelain, pendingCommitAuthorization = false }) {
432
+ export function decideSubagentStopGuard({ cwd, porcelain, pendingCommitAuthorization = false, orchestratorOwnsCommit = false }) {
412
433
  if (typeof cwd !== "string" || !isUnderWorktreePath(cwd)) {
413
434
  return ALLOW;
414
435
  }
415
- if (pendingCommitAuthorization) {
436
+ if (pendingCommitAuthorization || orchestratorOwnsCommit) {
416
437
  return ALLOW;
417
438
  }
418
439
  if (typeof porcelain !== "string" || porcelain.trim() === "") {
@@ -422,6 +443,17 @@ export function decideSubagentStopGuard({ cwd, porcelain, pendingCommitAuthoriza
422
443
  .split("\n")
423
444
  .map((l) => l.trim())
424
445
  .filter(Boolean);
446
+ // The reason is fed back to the stopping subagent as context, so the path
447
+ // enumeration is capped: an unbounded list on a very dirty worktree produces a
448
+ // multi-megabyte reason that consumers truncate or choke on, burying the one
449
+ // actionable line. The "Dirty paths (N):" header below always carries the full
450
+ // dirty count; the trailing "… and X more" line (when present) carries the
451
+ // remaining count past the cap, not the full total.
452
+ const MAX_LISTED_DIRTY_PATHS = 50;
453
+ const listed = dirty.slice(0, MAX_LISTED_DIRTY_PATHS).map((p) => " " + p);
454
+ if (dirty.length > MAX_LISTED_DIRTY_PATHS) {
455
+ listed.push(` … and ${dirty.length - MAX_LISTED_DIRTY_PATHS} more (run \`git status --porcelain\` for the full list)`);
456
+ }
425
457
  return {
426
458
  decision: "block",
427
459
  reason:
@@ -429,6 +461,6 @@ export function decideSubagentStopGuard({ cwd, porcelain, pendingCommitAuthoriza
429
461
  "to prevent silent data loss from post-merge worktree cleanup (cleanup-worktree.mjs runs " +
430
462
  "`git worktree remove --force`). Commit your work before stopping. " +
431
463
  `Dirty paths (${dirty.length}):\n` +
432
- dirty.map((p) => " " + p).join("\n"),
464
+ listed.join("\n"),
433
465
  };
434
466
  }
@@ -1,4 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
2
4
  import { parseArgs } from "node:util";
3
5
 
4
6
  /**
@@ -113,7 +115,7 @@ export function parsePositiveInteger(value, flag, parseError = null) {
113
115
 
114
116
  export function parseNonNegativeInteger(value, flag, parseError = null) {
115
117
  if (!/^\d+$/.test(value)) {
116
- throw toCliError(`${flag} must be a non-negative integer`, parseError);
118
+ throw toCliError(`${flag} must be a non-negative integer, got "${value}"`, parseError);
117
119
  }
118
120
  return Number(value);
119
121
  }
@@ -167,6 +169,33 @@ export function runChild(command, args, env = process.env, stdinText = undefined
167
169
  });
168
170
  }
169
171
 
172
+ /**
173
+ * Resolve a `--body <text>` / `--body-file <path>` pair to the actual body
174
+ * string, the one file-reading step every `--body-file` CLI flag in this repo
175
+ * needs: read the file (or stdin, when `allowStdin` and `bodyFile === "-"`)
176
+ * and FAIL CLOSED (throw) on an empty/whitespace-only result, so a blank or
177
+ * unreadable `--body-file` can never silently clear a PR/comment body. When
178
+ * `bodyFile` is absent, `body` is returned unchanged (no emptiness check —
179
+ * callers that must reject an empty inline `--body` do that themselves,
180
+ * since not every caller enforces it).
181
+ *
182
+ * @param {object} input
183
+ * @param {string} [input.body] — inline body value, returned as-is when no bodyFile
184
+ * @param {string} [input.bodyFile] — path to read the body from ("-" = stdin, when allowStdin)
185
+ * @param {boolean} [input.allowStdin] — honor `bodyFile === "-"` as stdin (fd 0); default false
186
+ * @returns {Promise<string | undefined>}
187
+ */
188
+ export async function resolveBodyOrFile({ body, bodyFile, allowStdin = false } = {}) {
189
+ if (bodyFile === undefined || bodyFile === null) return body;
190
+ // fs/promises readFile does not accept an integer fd, so stdin (fd 0) is
191
+ // read synchronously via readFileSync, which does accept one.
192
+ const content = allowStdin && bodyFile === "-" ? readFileSync(0, "utf8") : await readFile(bodyFile, "utf8");
193
+ if (content.trim().length === 0) {
194
+ throw new Error(`--body-file ${bodyFile} is empty`);
195
+ }
196
+ return content;
197
+ }
198
+
170
199
  export function runCommand(command, args, { cwd = process.cwd(), env = process.env } = {}) {
171
200
  return new Promise((resolve, reject) => {
172
201
  const child = spawn(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });