@codyswann/lisa 4.4.6 → 4.4.8

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 (151) hide show
  1. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  2. package/dist/core/upstream-evidence-manifest.js +29 -16
  3. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  4. package/package.json +1 -1
  5. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  6. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  7. package/plugins/lisa/.codex-plugin/skills/lisa-drive-pr-to-merge/SKILL.md +30 -1
  8. package/plugins/lisa/.codex-plugin/skills/lisa-remote-dispatch/scripts/dispatch.mjs +4 -3
  9. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/providers.mjs +11 -5
  10. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/sync-secret-to-ci.mjs +3 -2
  11. package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +36 -23
  12. package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +7 -3
  13. package/plugins/lisa/.codex-plugin/skills/lisa-setup-workstation/scripts/bounded-child.mjs +159 -0
  14. package/plugins/lisa/.codex-plugin/skills/lisa-setup-workstation/scripts/workstation.mjs +5 -4
  15. package/plugins/lisa/scripts/design-source-gate.mjs +19 -1
  16. package/plugins/lisa/scripts/plugin-sync-explain.mjs +23 -1
  17. package/plugins/lisa/scripts/project-ideation-idempotency-harness.mjs +20 -1
  18. package/plugins/lisa/skills/lisa-drive-pr-to-merge/SKILL.md +30 -1
  19. package/plugins/lisa/skills/lisa-remote-dispatch/scripts/dispatch.mjs +4 -3
  20. package/plugins/lisa/skills/lisa-secrets-access/scripts/providers.mjs +11 -5
  21. package/plugins/lisa/skills/lisa-secrets-access/scripts/sync-secret-to-ci.mjs +3 -2
  22. package/plugins/lisa/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +36 -23
  23. package/plugins/lisa/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +7 -3
  24. package/plugins/lisa/skills/lisa-setup-workstation/scripts/bounded-child.mjs +159 -0
  25. package/plugins/lisa/skills/lisa-setup-workstation/scripts/workstation.mjs +5 -4
  26. package/plugins/lisa-agy/plugin.json +1 -1
  27. package/plugins/lisa-agy/scripts/design-source-gate.mjs +19 -1
  28. package/plugins/lisa-agy/scripts/plugin-sync-explain.mjs +23 -1
  29. package/plugins/lisa-agy/scripts/project-ideation-idempotency-harness.mjs +20 -1
  30. package/plugins/lisa-agy/skills/lisa-drive-pr-to-merge/SKILL.md +30 -1
  31. package/plugins/lisa-agy/skills/lisa-remote-dispatch/scripts/dispatch.mjs +4 -3
  32. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/providers.mjs +11 -5
  33. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/sync-secret-to-ci.mjs +3 -2
  34. package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +36 -23
  35. package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +7 -3
  36. package/plugins/lisa-agy/skills/lisa-setup-workstation/scripts/bounded-child.mjs +159 -0
  37. package/plugins/lisa-agy/skills/lisa-setup-workstation/scripts/workstation.mjs +5 -4
  38. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  40. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  41. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-copilot/scripts/design-source-gate.mjs +19 -1
  45. package/plugins/lisa-copilot/scripts/plugin-sync-explain.mjs +23 -1
  46. package/plugins/lisa-copilot/scripts/project-ideation-idempotency-harness.mjs +20 -1
  47. package/plugins/lisa-copilot/skills/lisa-drive-pr-to-merge/SKILL.md +30 -1
  48. package/plugins/lisa-copilot/skills/lisa-remote-dispatch/scripts/dispatch.mjs +4 -3
  49. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/providers.mjs +11 -5
  50. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/sync-secret-to-ci.mjs +3 -2
  51. package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +36 -23
  52. package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +7 -3
  53. package/plugins/lisa-copilot/skills/lisa-setup-workstation/scripts/bounded-child.mjs +159 -0
  54. package/plugins/lisa-copilot/skills/lisa-setup-workstation/scripts/workstation.mjs +5 -4
  55. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-cursor/scripts/design-source-gate.mjs +19 -1
  57. package/plugins/lisa-cursor/scripts/plugin-sync-explain.mjs +23 -1
  58. package/plugins/lisa-cursor/scripts/project-ideation-idempotency-harness.mjs +20 -1
  59. package/plugins/lisa-cursor/skills/lisa-drive-pr-to-merge/SKILL.md +30 -1
  60. package/plugins/lisa-cursor/skills/lisa-remote-dispatch/scripts/dispatch.mjs +4 -3
  61. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/providers.mjs +11 -5
  62. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/sync-secret-to-ci.mjs +3 -2
  63. package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +36 -23
  64. package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +7 -3
  65. package/plugins/lisa-cursor/skills/lisa-setup-workstation/scripts/bounded-child.mjs +159 -0
  66. package/plugins/lisa-cursor/skills/lisa-setup-workstation/scripts/workstation.mjs +5 -4
  67. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  69. package/plugins/lisa-expo-agy/plugin.json +1 -1
  70. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  74. package/plugins/lisa-harper-fabric/hooks/enforce-config-extensions.mjs +55 -0
  75. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  76. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-harper-fabric-copilot/hooks/enforce-config-extensions.mjs +55 -0
  78. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-harper-fabric-cursor/hooks/enforce-config-extensions.mjs +55 -0
  80. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  82. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  83. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  87. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  88. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  89. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  90. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  92. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  93. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  94. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  95. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  96. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  97. package/plugins/lisa-rails-agy/plugin.json +1 -1
  98. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  99. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  100. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  101. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  102. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  103. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  104. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  105. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  106. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  107. package/plugins/lisa-wiki/scripts/diff-guard.mjs +3 -2
  108. package/plugins/lisa-wiki/scripts/ensure-wiki.mjs +3 -2
  109. package/plugins/lisa-wiki/scripts/ingest-git.mjs +12 -6
  110. package/plugins/lisa-wiki/scripts/lib/bounded-child.mjs +159 -0
  111. package/plugins/lisa-wiki/scripts/verify-migration.mjs +3 -2
  112. package/plugins/lisa-wiki/scripts/verify-wiki-safety.mjs +15 -5
  113. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  114. package/plugins/lisa-wiki-agy/scripts/diff-guard.mjs +3 -2
  115. package/plugins/lisa-wiki-agy/scripts/ensure-wiki.mjs +3 -2
  116. package/plugins/lisa-wiki-agy/scripts/ingest-git.mjs +12 -6
  117. package/plugins/lisa-wiki-agy/scripts/lib/bounded-child.mjs +159 -0
  118. package/plugins/lisa-wiki-agy/scripts/verify-migration.mjs +3 -2
  119. package/plugins/lisa-wiki-agy/scripts/verify-wiki-safety.mjs +15 -5
  120. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  121. package/plugins/lisa-wiki-copilot/scripts/diff-guard.mjs +3 -2
  122. package/plugins/lisa-wiki-copilot/scripts/ensure-wiki.mjs +3 -2
  123. package/plugins/lisa-wiki-copilot/scripts/ingest-git.mjs +12 -6
  124. package/plugins/lisa-wiki-copilot/scripts/lib/bounded-child.mjs +159 -0
  125. package/plugins/lisa-wiki-copilot/scripts/verify-migration.mjs +3 -2
  126. package/plugins/lisa-wiki-copilot/scripts/verify-wiki-safety.mjs +15 -5
  127. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  128. package/plugins/lisa-wiki-cursor/scripts/diff-guard.mjs +3 -2
  129. package/plugins/lisa-wiki-cursor/scripts/ensure-wiki.mjs +3 -2
  130. package/plugins/lisa-wiki-cursor/scripts/ingest-git.mjs +12 -6
  131. package/plugins/lisa-wiki-cursor/scripts/lib/bounded-child.mjs +159 -0
  132. package/plugins/lisa-wiki-cursor/scripts/verify-migration.mjs +3 -2
  133. package/plugins/lisa-wiki-cursor/scripts/verify-wiki-safety.mjs +15 -5
  134. package/plugins/src/base/scripts/design-source-gate.mjs +19 -1
  135. package/plugins/src/base/scripts/plugin-sync-explain.mjs +23 -1
  136. package/plugins/src/base/scripts/project-ideation-idempotency-harness.mjs +20 -1
  137. package/plugins/src/base/skills/lisa-drive-pr-to-merge/SKILL.md +30 -1
  138. package/plugins/src/base/skills/lisa-remote-dispatch/scripts/dispatch.mjs +4 -3
  139. package/plugins/src/base/skills/lisa-secrets-access/scripts/providers.mjs +11 -5
  140. package/plugins/src/base/skills/lisa-secrets-access/scripts/sync-secret-to-ci.mjs +3 -2
  141. package/plugins/src/base/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +36 -23
  142. package/plugins/src/base/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +7 -3
  143. package/plugins/src/base/skills/lisa-setup-workstation/scripts/bounded-child.mjs +159 -0
  144. package/plugins/src/base/skills/lisa-setup-workstation/scripts/workstation.mjs +5 -4
  145. package/plugins/src/harper-fabric/hooks/enforce-config-extensions.mjs +55 -0
  146. package/plugins/src/wiki/scripts/diff-guard.mjs +3 -2
  147. package/plugins/src/wiki/scripts/ensure-wiki.mjs +3 -2
  148. package/plugins/src/wiki/scripts/ingest-git.mjs +12 -6
  149. package/plugins/src/wiki/scripts/lib/bounded-child.mjs +159 -0
  150. package/plugins/src/wiki/scripts/verify-migration.mjs +3 -2
  151. package/plugins/src/wiki/scripts/verify-wiki-safety.mjs +15 -5
@@ -28,7 +28,6 @@
28
28
  * @module setup-remote-env
29
29
  */
30
30
 
31
- import { execFileSync } from "node:child_process";
32
31
  import { createHash } from "node:crypto";
33
32
  import {
34
33
  chmodSync,
@@ -51,6 +50,8 @@ import { pathDirs } from "../../lisa-setup-workstation/scripts/catalogue.mjs";
51
50
 
52
51
  import { assertPinned, extractVersion, planToolchain } from "./toolchain.mjs";
53
52
 
53
+ import { boundedChildOutput } from "../../lisa-setup-workstation/scripts/bounded-child.mjs";
54
+
54
55
  const HERE = dirname(fileURLToPath(import.meta.url));
55
56
 
56
57
  /**
@@ -117,7 +118,7 @@ export function readRemoteEnvConfig(cwd = process.cwd()) {
117
118
  * @param {Function} [exec] Command runner, for tests.
118
119
  * @returns {{version: string|null, present: boolean}} Probe result.
119
120
  */
120
- export function probe(name, exec = execFileSync) {
121
+ export function probe(name, exec = boundedChildOutput) {
121
122
  try {
122
123
  const out = exec(name, ["--version"], {
123
124
  encoding: "utf8",
@@ -169,19 +170,23 @@ function installReleaseZip(tool, binDir) {
169
170
  mkdirSync(temporary, { recursive: true });
170
171
  try {
171
172
  const archive = join(temporary, "download.zip");
172
- execFileSync("curl", ["-fsSL", tool.url, "-o", archive], {
173
+ boundedChildOutput("curl", ["-fsSL", tool.url, "-o", archive], {
173
174
  stdio: "inherit",
174
175
  });
175
176
  // Verify before unpacking, not after. An unexpected archive must fail
176
177
  // before any of its contents reach a directory that is on PATH.
177
178
  verifyChecksum(archive, tool.sha256, tool.name);
178
- execFileSync("unzip", ["-q", "-o", archive, "-d", temporary], {
179
+ boundedChildOutput("unzip", ["-q", "-o", archive, "-d", temporary], {
179
180
  stdio: "inherit",
180
181
  });
181
182
  const binary = join(temporary, tool.binary ?? tool.name);
182
- execFileSync("install", ["-m", "0755", binary, join(binDir, tool.name)], {
183
- stdio: "inherit",
184
- });
183
+ boundedChildOutput(
184
+ "install",
185
+ ["-m", "0755", binary, join(binDir, tool.name)],
186
+ {
187
+ stdio: "inherit",
188
+ }
189
+ );
185
190
  } finally {
186
191
  rmSync(temporary, { recursive: true, force: true });
187
192
  }
@@ -202,19 +207,23 @@ function installReleaseTar(tool, binDir) {
202
207
  mkdirSync(temporary, { recursive: true });
203
208
  try {
204
209
  const archive = join(temporary, "download.tar.gz");
205
- execFileSync("curl", ["-fsSL", tool.url, "-o", archive], {
210
+ boundedChildOutput("curl", ["-fsSL", tool.url, "-o", archive], {
206
211
  stdio: "inherit",
207
212
  });
208
213
  verifyChecksum(archive, tool.sha256, tool.name);
209
- execFileSync("tar", ["-xzf", archive, "-C", temporary], {
214
+ boundedChildOutput("tar", ["-xzf", archive, "-C", temporary], {
210
215
  stdio: "inherit",
211
216
  });
212
217
  // Release tarballs usually nest under a versioned directory, so `binary` is
213
218
  // a path within the archive rather than a bare name.
214
219
  const binary = join(temporary, tool.binary ?? tool.name);
215
- execFileSync("install", ["-m", "0755", binary, join(binDir, tool.name)], {
216
- stdio: "inherit",
217
- });
220
+ boundedChildOutput(
221
+ "install",
222
+ ["-m", "0755", binary, join(binDir, tool.name)],
223
+ {
224
+ stdio: "inherit",
225
+ }
226
+ );
218
227
  } finally {
219
228
  rmSync(temporary, { recursive: true, force: true });
220
229
  }
@@ -260,7 +269,7 @@ function installReleaseTree(tool, binDir) {
260
269
  mkdirSync(temporary, { recursive: true });
261
270
  try {
262
271
  const archive = join(temporary, "download.archive");
263
- execFileSync("curl", ["-fsSL", tool.url, "-o", archive], {
272
+ boundedChildOutput("curl", ["-fsSL", tool.url, "-o", archive], {
264
273
  stdio: "inherit",
265
274
  });
266
275
  // Verify before unpacking, as everywhere else here: an unexpected archive
@@ -273,11 +282,11 @@ function installReleaseTree(tool, binDir) {
273
282
  rmSync(prefix, { recursive: true, force: true });
274
283
  mkdirSync(prefix, { recursive: true });
275
284
  if (tool.url.endsWith(".zip")) {
276
- execFileSync("unzip", ["-q", "-o", archive, "-d", prefix], {
285
+ boundedChildOutput("unzip", ["-q", "-o", archive, "-d", prefix], {
277
286
  stdio: "inherit",
278
287
  });
279
288
  } else {
280
- execFileSync("tar", ["-xzf", archive, "-C", prefix], {
289
+ boundedChildOutput("tar", ["-xzf", archive, "-C", prefix], {
281
290
  stdio: "inherit",
282
291
  });
283
292
  }
@@ -330,7 +339,7 @@ function installReleaseBinary(tool, binDir) {
330
339
  mkdirSync(temporary, { recursive: true });
331
340
  try {
332
341
  const artifact = join(temporary, tool.name);
333
- execFileSync("curl", ["-fsSL", tool.url, "-o", artifact], {
342
+ boundedChildOutput("curl", ["-fsSL", tool.url, "-o", artifact], {
334
343
  stdio: "inherit",
335
344
  });
336
345
  // Verify before it reaches a directory on PATH. For this kind that ordering
@@ -338,9 +347,13 @@ function installReleaseBinary(tool, binDir) {
338
347
  // executable, so a wrong artifact placed first is a wrong artifact that can
339
348
  // be run.
340
349
  verifyChecksum(artifact, tool.sha256, tool.name);
341
- execFileSync("install", ["-m", "0755", artifact, join(binDir, tool.name)], {
342
- stdio: "inherit",
343
- });
350
+ boundedChildOutput(
351
+ "install",
352
+ ["-m", "0755", artifact, join(binDir, tool.name)],
353
+ {
354
+ stdio: "inherit",
355
+ }
356
+ );
344
357
  } finally {
345
358
  rmSync(temporary, { recursive: true, force: true });
346
359
  }
@@ -351,7 +364,7 @@ function installReleaseBinary(tool, binDir) {
351
364
  * @param {object} tool Manifest entry.
352
365
  */
353
366
  function installNpmGlobal(tool) {
354
- execFileSync(
367
+ boundedChildOutput(
355
368
  "npm",
356
369
  ["install", "--global", `${tool.package}@${tool.version}`],
357
370
  {
@@ -495,7 +508,7 @@ function runHook(hook, dryRun) {
495
508
  console.log(`\nProject hook: ${hook}`);
496
509
  if (dryRun) return;
497
510
  chmodSync(path, 0o755);
498
- execFileSync("bash", [path], { stdio: "inherit" });
511
+ boundedChildOutput("bash", [path], { stdio: "inherit" });
499
512
  }
500
513
 
501
514
  /** Phases this runner can execute, in the order they must happen. */
@@ -1181,7 +1194,7 @@ export function emitContainer({
1181
1194
  * skills has no detector, and that is not a reason to refuse to provision.
1182
1195
  * @param {Function} [exec] Seam for tests.
1183
1196
  */
1184
- function reportUndeclaredTooling(exec = execFileSync) {
1197
+ function reportUndeclaredTooling(exec = boundedChildOutput) {
1185
1198
  let script;
1186
1199
  try {
1187
1200
  script = siblingScript("lisa-detect-tooling", "detect-tooling.mjs");
@@ -1489,7 +1502,7 @@ async function main() {
1489
1502
  if (hook.reason) console.log(` ${hook.reason}`);
1490
1503
  }
1491
1504
  try {
1492
- execFileSync(
1505
+ boundedChildOutput(
1493
1506
  "node",
1494
1507
  dryRun ? [materialize, "--dry-run"] : [materialize],
1495
1508
  { stdio: "inherit" }
@@ -21,7 +21,6 @@
21
21
  * @module verify-remote-env
22
22
  */
23
23
 
24
- import { execFileSync } from "node:child_process";
25
24
  import { fileURLToPath } from "node:url";
26
25
  import { existsSync, readFileSync, statSync, realpathSync } from "node:fs";
27
26
  import { join } from "node:path";
@@ -29,6 +28,8 @@ import { join } from "node:path";
29
28
  import { probe, readRemoteEnvConfig } from "./setup-remote-env.mjs";
30
29
  import { planToolchain } from "./toolchain.mjs";
31
30
 
31
+ import { boundedChildOutput } from "../../lisa-setup-workstation/scripts/bounded-child.mjs";
32
+
32
33
  /** Collected results, so every check runs before anything reports failure. */
33
34
  const results = [];
34
35
 
@@ -73,12 +74,15 @@ function checkMode(path, mode, label) {
73
74
  */
74
75
  function node(args) {
75
76
  try {
76
- const out = execFileSync("node", args, {
77
+ const out = boundedChildOutput("node", args, {
77
78
  encoding: "utf8",
78
79
  stdio: ["ignore", "pipe", "pipe"],
79
80
  });
80
81
  return { ok: true, out: out.trim() };
81
82
  } catch (err) {
83
+ // KEPT: `ok: false` fails the check either way, and a killed child's
84
+ // `err.message` already reads "... ETIMEDOUT", so the timeout names
85
+ // itself. This is the shape the others are being moved toward.
82
86
  return { ok: false, out: String(err.stderr ?? err.message).trim() };
83
87
  }
84
88
  }
@@ -149,7 +153,7 @@ export function verifyNotProxied(required, env, report) {
149
153
  */
150
154
  function verifyCleanCheckout() {
151
155
  try {
152
- const out = execFileSync("git", ["status", "--porcelain"], {
156
+ const out = boundedChildOutput("git", ["status", "--porcelain"], {
153
157
  encoding: "utf8",
154
158
  stdio: ["ignore", "pipe", "ignore"],
155
159
  });
@@ -0,0 +1,159 @@
1
+ /**
2
+ * bounded-child — the plugin payload's child-start deadline.
3
+ *
4
+ * @remarks
5
+ * ## Why this exists separately from `scripts/lib/bounded-spawn.mjs`
6
+ *
7
+ * Same property, different reach. A plugin payload cannot import from
8
+ * `scripts/lib/`: the built plugin is a self-contained copy of
9
+ * `plugins/src/<name>/`, and nothing outside that tree exists at runtime. So the
10
+ * rule is written out here rather than pointed at — the accommodation
11
+ * `threshold-ratchet.mjs` and `preflight-secrets.mjs` already make for their
12
+ * entry guards.
13
+ *
14
+ * It is a MODULE rather than 28 inline copies because the fanout preserves
15
+ * relative imports. Measured in the built output, not assumed:
16
+ * `plugins/lisa/skills/lisa-setup-workstation/scripts/catalogue.mjs` exists and
17
+ * is imported as `../../lisa-setup-workstation/scripts/catalogue.mjs` from a
18
+ * sibling skill. The build copies the plugin tree wholesale, so a path that
19
+ * resolves in `plugins/src/base/` resolves in all five copies.
20
+ *
21
+ * That distinction matters because the accommodation above is narrower than its
22
+ * wording suggests. `threshold-ratchet.mjs` cannot import a sibling because it
23
+ * is **materialized into other lanes** — `typescript/`, `rails/` — where the
24
+ * sibling does not exist at the destination. A file that lives only in this
25
+ * plugin has no such problem.
26
+ *
27
+ * ## The one thing a deadline alone does not fix
28
+ *
29
+ * `spawnSync` does not throw when it kills a child. It returns:
30
+ *
31
+ * | outcome | `.code` | `.status` | `.signal` | streams |
32
+ * | --- | --- | --- | --- | --- |
33
+ * | killed at deadline | `"ETIMEDOUT"` | `null` | `"SIGKILL"` | **empty** |
34
+ * | ran, exited non-zero | *(absent)* | `3` | `null` | real |
35
+ * | binary not found | `"ENOENT"` | `null` | `null` | empty |
36
+ *
37
+ * So the shape that appears all over this tree —
38
+ *
39
+ * ```js
40
+ * return result.status === 0 ? result.stdout : null;
41
+ * ```
42
+ *
43
+ * — takes the benign branch for a killed child exactly as it does for a program
44
+ * that ran and said no. **Adding `timeout:` gives such a call site a deadline
45
+ * and changes nothing about its verdict.** There is no `catch` to fix, because
46
+ * the fail-open is in the conditional.
47
+ *
48
+ * That is why `boundedChild` THROWS rather than returning a flag: a caller that
49
+ * does nothing inherits fail-closed behaviour, and only one that deliberately
50
+ * catches can proceed.
51
+ * ## Why it lives in THIS skill's directory
52
+ *
53
+ * It is not about workstation setup, and it is here anyway, for a measured
54
+ * reason. The built Codex artifact — `plugins/lisa/.codex-plugin/` — copies
55
+ * `skills/` and nothing else: no `scripts/`, `agents/`, `commands/` or
56
+ * `rules/`. So a skill script importing `../../../scripts/lib/…` resolves to a
57
+ * path that does not exist in that copy, while `../../<skill>/scripts/…`
58
+ * resolves everywhere, which is exactly why `catalogue.mjs` beside this file is
59
+ * already imported that way from a sibling skill.
60
+ *
61
+ * A directory of its own under `skills/` was the tidier home and was rejected
62
+ * on evidence: every `skills/` subdirectory in this tree is a real skill with a
63
+ * `SKILL.md`, the generators parse that file to build their artifacts, and a
64
+ * directory without one is a shape nothing here has ever produced. Choosing an
65
+ * unproven layout is what put the import in the wrong place to begin with.
66
+ *
67
+ * **Do not delete this as unused from its host skill.** Its importers are in
68
+ * other skills and in `../../../scripts/`; `lisa-setup-workstation` hosts it,
69
+ * it does not own it.
70
+ * @module bounded-child
71
+ */
72
+
73
+ import { execFileSync, spawnSync } from "node:child_process";
74
+
75
+ /**
76
+ * Deadline a child gets when the caller states none, in milliseconds.
77
+ *
78
+ * A hang detector, not a performance budget. `git` resolved through `PATH` on
79
+ * macOS goes via Apple's `xcrun` shim, measured at over 20 seconds under load
80
+ * against 11ms for a real binary (CodySwannGT/lisa#2887) — so a tighter
81
+ * deadline would make this module's own timeout the dominant failure mode, and
82
+ * the tool would start manufacturing the failures it exists to detect.
83
+ */
84
+ export const CHILD_BUDGET_MS = 30_000;
85
+
86
+ /**
87
+ * Whether this value is a child that was killed at its deadline.
88
+ *
89
+ * Reads `code` rather than an error class, because `ETIMEDOUT` is set by Node
90
+ * itself on all three call forms — it is a platform fact, not a convention this
91
+ * module invented, so the same test is correct anywhere including inline.
92
+ *
93
+ * Defensive about the shape: a `catch` binding is `unknown` and a thrown
94
+ * non-object is legal JavaScript, so this must answer `false` rather than crash
95
+ * the guard that was being careful.
96
+ * @param {unknown} error A caught value, or `result.error` from `spawnSync`.
97
+ * @returns {boolean} True when the child was killed at its deadline.
98
+ */
99
+ export function isChildTimeout(error) {
100
+ return (
101
+ typeof error === "object" &&
102
+ error !== null &&
103
+ /** @type {{ code?: unknown }} */ (error).code === "ETIMEDOUT"
104
+ );
105
+ }
106
+
107
+ /**
108
+ * Re-raise a caught value when it is a killed child, otherwise do nothing.
109
+ *
110
+ * The correct spelling is `throw error` — not wrapping — because a wrapped
111
+ * error loses the `code` the next frame up needs to make the same decision.
112
+ * @param {unknown} error The caught value.
113
+ * @returns {void}
114
+ * @throws {unknown} The original value, when it is a killed child.
115
+ */
116
+ export function rethrowIfChildTimeout(error) {
117
+ if (isChildTimeout(error)) throw error;
118
+ }
119
+
120
+ /**
121
+ * `spawnSync` with a deadline, refusing to return a killed child's result.
122
+ * @param {string} command The executable.
123
+ * @param {readonly string[]} args Arguments.
124
+ * @param {object} [options] `spawnSync` options; `timeout` overrides the default.
125
+ * @returns {import("node:child_process").SpawnSyncReturns<string>} The result.
126
+ * @throws {Error} When the child was killed at its deadline.
127
+ */
128
+ export function boundedChild(command, args = [], options = {}) {
129
+ const result = spawnSync(command, [...args], {
130
+ ...options,
131
+ // `SIGKILL` rather than the default `SIGTERM`: the hang this exists for is
132
+ // a process not servicing signals, and a `SIGTERM` a child ignores turns
133
+ // the deadline into a suggestion.
134
+ killSignal: "SIGKILL",
135
+ timeout: options.timeout ?? CHILD_BUDGET_MS,
136
+ });
137
+ if (isChildTimeout(result.error)) throw result.error;
138
+ return result;
139
+ }
140
+
141
+ /**
142
+ * `execFileSync` with a deadline.
143
+ *
144
+ * `execFileSync` already throws on a timeout, so this changes the FAILURE MODE
145
+ * rather than adding one: without a `timeout` there is no deadline to reach and
146
+ * the call blocks for as long as the child lives.
147
+ * @param {string} command The executable.
148
+ * @param {readonly string[]} args Arguments.
149
+ * @param {object} [options] `execFileSync` options; `timeout` overrides the default.
150
+ * @returns {string|Buffer} Whatever the child wrote to stdout.
151
+ * @throws {Error} When the child was killed, or exited non-zero.
152
+ */
153
+ export function boundedChildOutput(command, args = [], options = {}) {
154
+ return execFileSync(command, [...args], {
155
+ ...options,
156
+ killSignal: "SIGKILL",
157
+ timeout: options.timeout ?? CHILD_BUDGET_MS,
158
+ });
159
+ }
@@ -33,10 +33,11 @@
33
33
  * @module workstation
34
34
  */
35
35
 
36
- import { execFileSync } from "node:child_process";
37
36
  import { existsSync, lstatSync, mkdirSync, rmSync, symlinkSync } from "node:fs";
38
37
  import { join } from "node:path";
39
38
 
39
+ import { boundedChildOutput } from "./bounded-child.mjs";
40
+
40
41
  import {
41
42
  AGENTS,
42
43
  CHECKSUMMED,
@@ -56,7 +57,7 @@ import {
56
57
  * @param {Function} [exec] Runner, injectable for tests.
57
58
  * @returns {string|null} Absolute path, or null when absent.
58
59
  */
59
- export function locate(name, exec = execFileSync) {
60
+ export function locate(name, exec = boundedChildOutput) {
60
61
  try {
61
62
  return String(
62
63
  exec("command", ["-v", name], { encoding: "utf8", shell: true })
@@ -76,7 +77,7 @@ export function locate(name, exec = execFileSync) {
76
77
  * @param {Function} [exec] Runner, injectable for tests.
77
78
  * @returns {string|null} A dotted version, or null.
78
79
  */
79
- export function version(name, exec = execFileSync) {
80
+ export function version(name, exec = boundedChildOutput) {
80
81
  const read = out => {
81
82
  const match = /(\d+(?:\.\d+)+)/.exec(String(out ?? ""));
82
83
  return match ? match[1] : null;
@@ -381,7 +382,7 @@ export function installEntry(entry, row, deps = {}) {
381
382
  const run =
382
383
  deps.run ??
383
384
  ((cmd, args, opts) =>
384
- execFileSync(cmd, args, { stdio: "inherit", ...opts }));
385
+ boundedChildOutput(cmd, args, { stdio: "inherit", ...opts }));
385
386
  const find = deps.locate ?? locate;
386
387
 
387
388
  try {
@@ -59,6 +59,44 @@ const gitEnv = () =>
59
59
  Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_"))
60
60
  );
61
61
 
62
+ /**
63
+ * Milliseconds before the baseline read is presumed hung.
64
+ *
65
+ * A hang detector, not a budget. `git` reached through PATH on macOS goes via
66
+ * Apple's `xcrun` shim, measured over 20 seconds under load against 11ms for a
67
+ * real binary (CodySwannGT/lisa#2887), so a tighter deadline would make this
68
+ * hook's own timeout the ordinary outcome on a busy machine.
69
+ */
70
+ const GIT_BUDGET_MS = 30_000;
71
+
72
+ /** The baseline could not be read at all, as distinct from "there is none". */
73
+ const UNREADABLE = Symbol("unreadable");
74
+
75
+ /**
76
+ * The config as HEAD holds it: its text, `null` when HEAD has no such file, or
77
+ * `UNREADABLE` when git could not be asked.
78
+ *
79
+ * THE THREE ANSWERS ARE DELIBERATELY THREE. This used to be
80
+ * `result.status === 0 ? result.stdout : null`, and `status === 0` is false for
81
+ * a file that does not exist at HEAD **and** for a child killed at its
82
+ * deadline — so both became `null`, and the caller reads `null` as "no baseline
83
+ * to compare against, allow the edit". A busy machine therefore let through
84
+ * exactly the extension removal this hook exists to block, and nothing anywhere
85
+ * said the word "time".
86
+ *
87
+ * A deadline alone does not fix that. `spawnSync` does not throw when it kills
88
+ * a child; it returns `{ status: null, stdout: "" }`, which takes the same
89
+ * branch it always did. The discrimination has to be written, not scheduled —
90
+ * which is why this call site needed more than the `timeout:` its siblings did.
91
+ *
92
+ * `ETIMEDOUT` is set by Node itself, so this is a platform fact rather than a
93
+ * convention invented here; the shared `bounded-child.mjs` reads the same field,
94
+ * and this hook writes it out inline because a plugin payload materialized as a
95
+ * hook has no module to import from.
96
+ * @param {string} repoRoot Repository root.
97
+ * @returns {string|null|symbol} The text, `null` when absent, `UNREADABLE` when
98
+ * the child was killed.
99
+ */
62
100
  const readGitBlob = repoRoot => {
63
101
  const result = spawnSync(
64
102
  "git",
@@ -66,8 +104,11 @@ const readGitBlob = repoRoot => {
66
104
  {
67
105
  encoding: "utf8",
68
106
  env: gitEnv(),
107
+ killSignal: "SIGKILL",
108
+ timeout: GIT_BUDGET_MS,
69
109
  }
70
110
  );
111
+ if (result.error?.code === "ETIMEDOUT") return UNREADABLE;
71
112
  return result.status === 0 ? result.stdout : null;
72
113
  };
73
114
 
@@ -104,6 +145,20 @@ const main = () => {
104
145
  }
105
146
 
106
147
  const previousText = readGitBlob(repoRoot);
148
+ // FAIL CLOSED on "could not ask", ALLOW on "there is nothing to compare".
149
+ // Those are different findings and only the second is safe to permit: a
150
+ // config with no baseline at HEAD is a new file, while a baseline nobody
151
+ // could read is an unanswered question, and a guard that permits an edit
152
+ // because it was too busy to check has not checked.
153
+ if (previousText === UNREADABLE) {
154
+ process.stderr.write(
155
+ `Blocked: could not read HEAD:${CONFIG_PATH} — git was killed after ` +
156
+ `${String(GIT_BUDGET_MS)}ms without finishing, so the previous ` +
157
+ `extensions are unknown. This is a timeout, not a finding: nothing ` +
158
+ `about the edit was measured. Re-run when the machine is quieter.\n`
159
+ );
160
+ return BLOCKED;
161
+ }
107
162
  if (previousText === null) return ALLOWED;
108
163
 
109
164
  const previousExtensions = topLevelExtensionKeys(previousText);
@@ -16,9 +16,10 @@
16
16
  * Usage: node diff-guard.mjs --allow <glob> [--allow <glob>...] [--base <ref>]
17
17
  * Exit 0 = all changes within allowed globs, 1 = out-of-bounds change (or git error).
18
18
  */
19
- import { execFileSync } from "node:child_process";
20
19
  import { globToRegExp } from "./_wiki-lib.mjs";
21
20
 
21
+ import { boundedChildOutput } from "./lib/bounded-child.mjs";
22
+
22
23
  function fail(msg) {
23
24
  console.error(`✗ ${msg}`);
24
25
  process.exit(1);
@@ -51,7 +52,7 @@ if (allows.length === 0) {
51
52
  }
52
53
 
53
54
  function git(args) {
54
- return execFileSync("git", args, { encoding: "utf8" });
55
+ return boundedChildOutput("git", args, { encoding: "utf8" });
55
56
  }
56
57
 
57
58
  let repoRoot;
@@ -49,7 +49,8 @@
49
49
  */
50
50
  import fs from "node:fs";
51
51
  import path from "node:path";
52
- import { execFileSync } from "node:child_process";
52
+
53
+ import { boundedChildOutput } from "./lib/bounded-child.mjs";
53
54
 
54
55
  const GITIGNORE_BEGIN = "# BEGIN: AI GUARDRAILS WIKI MIRROR";
55
56
  const GITIGNORE_END = "# END: AI GUARDRAILS WIKI MIRROR";
@@ -71,7 +72,7 @@ function readJsonSafe(file) {
71
72
  }
72
73
  }
73
74
  function git(args, cwd) {
74
- return execFileSync("git", args, {
75
+ return boundedChildOutput("git", args, {
75
76
  cwd,
76
77
  stdio: ["ignore", "pipe", "pipe"],
77
78
  encoding: "utf8",
@@ -14,9 +14,13 @@
14
14
  */
15
15
  import fs from "node:fs";
16
16
  import path from "node:path";
17
- import { execFileSync } from "node:child_process";
18
17
  import { readJsonSafe, writeSanitizedSourceNote } from "./_wiki-lib.mjs";
19
18
 
19
+ import {
20
+ boundedChildOutput,
21
+ rethrowIfChildTimeout,
22
+ } from "./lib/bounded-child.mjs";
23
+
20
24
  const argv = process.argv.slice(2);
21
25
  const opt = (n, d) => {
22
26
  const i = argv.indexOf(n);
@@ -35,7 +39,7 @@ const fail = m => {
35
39
  process.exit(1);
36
40
  };
37
41
  const git = args =>
38
- execFileSync("git", ["-C", repo, ...args], { encoding: "utf8" }).trim();
42
+ boundedChildOutput("git", ["-C", repo, ...args], { encoding: "utf8" }).trim();
39
43
  const tryGit = args => {
40
44
  try {
41
45
  return git(args);
@@ -45,11 +49,13 @@ const tryGit = args => {
45
49
  };
46
50
  const commitExists = c => {
47
51
  try {
48
- execFileSync("git", ["-C", repo, "cat-file", "-e", `${c}^{commit}`], {
52
+ boundedChildOutput("git", ["-C", repo, "cat-file", "-e", `${c}^{commit}`], {
49
53
  stdio: "ignore",
50
54
  });
51
55
  return true;
52
- } catch {
56
+ } catch (error) {
57
+ // `false` is a verdict of "absent", not an admission of ignorance.
58
+ rethrowIfChildTimeout(error);
53
59
  return false;
54
60
  }
55
61
  };
@@ -87,7 +93,7 @@ let prSummary = "(no GitHub repo resolved — PR history skipped)";
87
93
  let ghRepo = githubRepo;
88
94
  if (!ghRepo) {
89
95
  try {
90
- ghRepo = execFileSync(
96
+ ghRepo = boundedChildOutput(
91
97
  "gh",
92
98
  ["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"],
93
99
  { cwd: repo, encoding: "utf8" }
@@ -99,7 +105,7 @@ if (!ghRepo) {
99
105
  if (ghRepo) {
100
106
  try {
101
107
  const prs = JSON.parse(
102
- execFileSync(
108
+ boundedChildOutput(
103
109
  "gh",
104
110
  [
105
111
  "pr",