@codyswann/lisa 4.4.6 → 4.4.7

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 (145) hide show
  1. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  2. package/dist/core/upstream-evidence-manifest.js +28 -15
  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-remote-dispatch/scripts/dispatch.mjs +4 -3
  8. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/providers.mjs +11 -5
  9. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/sync-secret-to-ci.mjs +3 -2
  10. package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +36 -23
  11. package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +7 -3
  12. package/plugins/lisa/.codex-plugin/skills/lisa-setup-workstation/scripts/bounded-child.mjs +159 -0
  13. package/plugins/lisa/.codex-plugin/skills/lisa-setup-workstation/scripts/workstation.mjs +5 -4
  14. package/plugins/lisa/scripts/design-source-gate.mjs +19 -1
  15. package/plugins/lisa/scripts/plugin-sync-explain.mjs +23 -1
  16. package/plugins/lisa/scripts/project-ideation-idempotency-harness.mjs +20 -1
  17. package/plugins/lisa/skills/lisa-remote-dispatch/scripts/dispatch.mjs +4 -3
  18. package/plugins/lisa/skills/lisa-secrets-access/scripts/providers.mjs +11 -5
  19. package/plugins/lisa/skills/lisa-secrets-access/scripts/sync-secret-to-ci.mjs +3 -2
  20. package/plugins/lisa/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +36 -23
  21. package/plugins/lisa/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +7 -3
  22. package/plugins/lisa/skills/lisa-setup-workstation/scripts/bounded-child.mjs +159 -0
  23. package/plugins/lisa/skills/lisa-setup-workstation/scripts/workstation.mjs +5 -4
  24. package/plugins/lisa-agy/plugin.json +1 -1
  25. package/plugins/lisa-agy/scripts/design-source-gate.mjs +19 -1
  26. package/plugins/lisa-agy/scripts/plugin-sync-explain.mjs +23 -1
  27. package/plugins/lisa-agy/scripts/project-ideation-idempotency-harness.mjs +20 -1
  28. package/plugins/lisa-agy/skills/lisa-remote-dispatch/scripts/dispatch.mjs +4 -3
  29. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/providers.mjs +11 -5
  30. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/sync-secret-to-ci.mjs +3 -2
  31. package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +36 -23
  32. package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +7 -3
  33. package/plugins/lisa-agy/skills/lisa-setup-workstation/scripts/bounded-child.mjs +159 -0
  34. package/plugins/lisa-agy/skills/lisa-setup-workstation/scripts/workstation.mjs +5 -4
  35. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  37. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  38. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-copilot/scripts/design-source-gate.mjs +19 -1
  42. package/plugins/lisa-copilot/scripts/plugin-sync-explain.mjs +23 -1
  43. package/plugins/lisa-copilot/scripts/project-ideation-idempotency-harness.mjs +20 -1
  44. package/plugins/lisa-copilot/skills/lisa-remote-dispatch/scripts/dispatch.mjs +4 -3
  45. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/providers.mjs +11 -5
  46. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/sync-secret-to-ci.mjs +3 -2
  47. package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +36 -23
  48. package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +7 -3
  49. package/plugins/lisa-copilot/skills/lisa-setup-workstation/scripts/bounded-child.mjs +159 -0
  50. package/plugins/lisa-copilot/skills/lisa-setup-workstation/scripts/workstation.mjs +5 -4
  51. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-cursor/scripts/design-source-gate.mjs +19 -1
  53. package/plugins/lisa-cursor/scripts/plugin-sync-explain.mjs +23 -1
  54. package/plugins/lisa-cursor/scripts/project-ideation-idempotency-harness.mjs +20 -1
  55. package/plugins/lisa-cursor/skills/lisa-remote-dispatch/scripts/dispatch.mjs +4 -3
  56. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/providers.mjs +11 -5
  57. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/sync-secret-to-ci.mjs +3 -2
  58. package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +36 -23
  59. package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +7 -3
  60. package/plugins/lisa-cursor/skills/lisa-setup-workstation/scripts/bounded-child.mjs +159 -0
  61. package/plugins/lisa-cursor/skills/lisa-setup-workstation/scripts/workstation.mjs +5 -4
  62. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  64. package/plugins/lisa-expo-agy/plugin.json +1 -1
  65. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  69. package/plugins/lisa-harper-fabric/hooks/enforce-config-extensions.mjs +55 -0
  70. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  71. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-harper-fabric-copilot/hooks/enforce-config-extensions.mjs +55 -0
  73. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-harper-fabric-cursor/hooks/enforce-config-extensions.mjs +55 -0
  75. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  77. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  78. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  82. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  83. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  87. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  88. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  89. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  90. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  92. package/plugins/lisa-rails-agy/plugin.json +1 -1
  93. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  94. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  95. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  96. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  97. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  98. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  99. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  100. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  101. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  102. package/plugins/lisa-wiki/scripts/diff-guard.mjs +3 -2
  103. package/plugins/lisa-wiki/scripts/ensure-wiki.mjs +3 -2
  104. package/plugins/lisa-wiki/scripts/ingest-git.mjs +12 -6
  105. package/plugins/lisa-wiki/scripts/lib/bounded-child.mjs +159 -0
  106. package/plugins/lisa-wiki/scripts/verify-migration.mjs +3 -2
  107. package/plugins/lisa-wiki/scripts/verify-wiki-safety.mjs +15 -5
  108. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  109. package/plugins/lisa-wiki-agy/scripts/diff-guard.mjs +3 -2
  110. package/plugins/lisa-wiki-agy/scripts/ensure-wiki.mjs +3 -2
  111. package/plugins/lisa-wiki-agy/scripts/ingest-git.mjs +12 -6
  112. package/plugins/lisa-wiki-agy/scripts/lib/bounded-child.mjs +159 -0
  113. package/plugins/lisa-wiki-agy/scripts/verify-migration.mjs +3 -2
  114. package/plugins/lisa-wiki-agy/scripts/verify-wiki-safety.mjs +15 -5
  115. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  116. package/plugins/lisa-wiki-copilot/scripts/diff-guard.mjs +3 -2
  117. package/plugins/lisa-wiki-copilot/scripts/ensure-wiki.mjs +3 -2
  118. package/plugins/lisa-wiki-copilot/scripts/ingest-git.mjs +12 -6
  119. package/plugins/lisa-wiki-copilot/scripts/lib/bounded-child.mjs +159 -0
  120. package/plugins/lisa-wiki-copilot/scripts/verify-migration.mjs +3 -2
  121. package/plugins/lisa-wiki-copilot/scripts/verify-wiki-safety.mjs +15 -5
  122. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  123. package/plugins/lisa-wiki-cursor/scripts/diff-guard.mjs +3 -2
  124. package/plugins/lisa-wiki-cursor/scripts/ensure-wiki.mjs +3 -2
  125. package/plugins/lisa-wiki-cursor/scripts/ingest-git.mjs +12 -6
  126. package/plugins/lisa-wiki-cursor/scripts/lib/bounded-child.mjs +159 -0
  127. package/plugins/lisa-wiki-cursor/scripts/verify-migration.mjs +3 -2
  128. package/plugins/lisa-wiki-cursor/scripts/verify-wiki-safety.mjs +15 -5
  129. package/plugins/src/base/scripts/design-source-gate.mjs +19 -1
  130. package/plugins/src/base/scripts/plugin-sync-explain.mjs +23 -1
  131. package/plugins/src/base/scripts/project-ideation-idempotency-harness.mjs +20 -1
  132. package/plugins/src/base/skills/lisa-remote-dispatch/scripts/dispatch.mjs +4 -3
  133. package/plugins/src/base/skills/lisa-secrets-access/scripts/providers.mjs +11 -5
  134. package/plugins/src/base/skills/lisa-secrets-access/scripts/sync-secret-to-ci.mjs +3 -2
  135. package/plugins/src/base/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +36 -23
  136. package/plugins/src/base/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +7 -3
  137. package/plugins/src/base/skills/lisa-setup-workstation/scripts/bounded-child.mjs +159 -0
  138. package/plugins/src/base/skills/lisa-setup-workstation/scripts/workstation.mjs +5 -4
  139. package/plugins/src/harper-fabric/hooks/enforce-config-extensions.mjs +55 -0
  140. package/plugins/src/wiki/scripts/diff-guard.mjs +3 -2
  141. package/plugins/src/wiki/scripts/ensure-wiki.mjs +3 -2
  142. package/plugins/src/wiki/scripts/ingest-git.mjs +12 -6
  143. package/plugins/src/wiki/scripts/lib/bounded-child.mjs +159 -0
  144. package/plugins/src/wiki/scripts/verify-migration.mjs +3 -2
  145. package/plugins/src/wiki/scripts/verify-wiki-safety.mjs +15 -5
@@ -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
+ }
@@ -12,10 +12,11 @@
12
12
  */
13
13
  import fs from "node:fs";
14
14
  import path from "node:path";
15
- import { spawnSync } from "node:child_process";
16
15
  import { fileURLToPath } from "node:url";
17
16
  import { loadConfig } from "./_wiki-lib.mjs";
18
17
 
18
+ import { boundedChild } from "./lib/bounded-child.mjs";
19
+
19
20
  const scriptDir = path.dirname(fileURLToPath(import.meta.url));
20
21
  const argv = process.argv.slice(2);
21
22
  const opt = n => {
@@ -31,7 +32,7 @@ const groups = { A: [], B: [], C: [], D: [], E: [], F: [], G: [] };
31
32
  const add = (g, id, status, message) => groups[g].push({ id, status, message });
32
33
 
33
34
  function runNode(script, args) {
34
- const res = spawnSync("node", [path.join(scriptDir, script), ...args], {
35
+ const res = boundedChild("node", [path.join(scriptDir, script), ...args], {
35
36
  encoding: "utf8",
36
37
  });
37
38
  return {
@@ -5,12 +5,16 @@
5
5
  * Scans only explicit wiki paths, or current git changes under the configured
6
6
  * wiki root when no paths are passed. Reports safe metadata only.
7
7
  */
8
- import { execFileSync } from "node:child_process";
9
8
  import fs from "node:fs";
10
9
  import path from "node:path";
11
10
  import { loadConfig } from "./_wiki-lib.mjs";
12
11
  import { scanWikiGeneratedFiles } from "./wiki-safety.mjs";
13
12
 
13
+ import {
14
+ boundedChildOutput,
15
+ rethrowIfChildTimeout,
16
+ } from "./lib/bounded-child.mjs";
17
+
14
18
  const argv = process.argv.slice(2);
15
19
  const flag = name => argv.includes(name);
16
20
  const opt = name => {
@@ -32,11 +36,14 @@ const scanner = opt("--scanner") ?? "builtin";
32
36
 
33
37
  function commandExists(command) {
34
38
  try {
35
- execFileSync("sh", ["-c", `command -v ${command}`], {
39
+ boundedChildOutput("sh", ["-c", `command -v ${command}`], {
36
40
  stdio: "ignore",
37
41
  });
38
42
  return true;
39
- } catch {
43
+ } catch (error) {
44
+ // "I could not check whether the tool exists" is not "the tool is
45
+ // absent" — and absent silently downgrades the scan to the builtin.
46
+ rethrowIfChildTimeout(error);
40
47
  return false;
41
48
  }
42
49
  }
@@ -44,10 +51,13 @@ function commandExists(command) {
44
51
  function gitChangedPaths() {
45
52
  let out = "";
46
53
  try {
47
- out = execFileSync("git", ["status", "--porcelain=v1", "-z"], {
54
+ out = boundedChildOutput("git", ["status", "--porcelain=v1", "-z"], {
48
55
  encoding: "utf8",
49
56
  });
50
- } catch {
57
+ } catch (error) {
58
+ // An empty change set means this safety check inspects NOTHING and
59
+ // passes. A busy machine would clear the wiki without reading it.
60
+ rethrowIfChildTimeout(error);
51
61
  return [];
52
62
  }
53
63
  const entries = out.split("\0").filter(Boolean);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.4.6",
3
+ "version": "4.4.7",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -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",
@@ -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
+ }
@@ -12,10 +12,11 @@
12
12
  */
13
13
  import fs from "node:fs";
14
14
  import path from "node:path";
15
- import { spawnSync } from "node:child_process";
16
15
  import { fileURLToPath } from "node:url";
17
16
  import { loadConfig } from "./_wiki-lib.mjs";
18
17
 
18
+ import { boundedChild } from "./lib/bounded-child.mjs";
19
+
19
20
  const scriptDir = path.dirname(fileURLToPath(import.meta.url));
20
21
  const argv = process.argv.slice(2);
21
22
  const opt = n => {
@@ -31,7 +32,7 @@ const groups = { A: [], B: [], C: [], D: [], E: [], F: [], G: [] };
31
32
  const add = (g, id, status, message) => groups[g].push({ id, status, message });
32
33
 
33
34
  function runNode(script, args) {
34
- const res = spawnSync("node", [path.join(scriptDir, script), ...args], {
35
+ const res = boundedChild("node", [path.join(scriptDir, script), ...args], {
35
36
  encoding: "utf8",
36
37
  });
37
38
  return {
@@ -5,12 +5,16 @@
5
5
  * Scans only explicit wiki paths, or current git changes under the configured
6
6
  * wiki root when no paths are passed. Reports safe metadata only.
7
7
  */
8
- import { execFileSync } from "node:child_process";
9
8
  import fs from "node:fs";
10
9
  import path from "node:path";
11
10
  import { loadConfig } from "./_wiki-lib.mjs";
12
11
  import { scanWikiGeneratedFiles } from "./wiki-safety.mjs";
13
12
 
13
+ import {
14
+ boundedChildOutput,
15
+ rethrowIfChildTimeout,
16
+ } from "./lib/bounded-child.mjs";
17
+
14
18
  const argv = process.argv.slice(2);
15
19
  const flag = name => argv.includes(name);
16
20
  const opt = name => {
@@ -32,11 +36,14 @@ const scanner = opt("--scanner") ?? "builtin";
32
36
 
33
37
  function commandExists(command) {
34
38
  try {
35
- execFileSync("sh", ["-c", `command -v ${command}`], {
39
+ boundedChildOutput("sh", ["-c", `command -v ${command}`], {
36
40
  stdio: "ignore",
37
41
  });
38
42
  return true;
39
- } catch {
43
+ } catch (error) {
44
+ // "I could not check whether the tool exists" is not "the tool is
45
+ // absent" — and absent silently downgrades the scan to the builtin.
46
+ rethrowIfChildTimeout(error);
40
47
  return false;
41
48
  }
42
49
  }
@@ -44,10 +51,13 @@ function commandExists(command) {
44
51
  function gitChangedPaths() {
45
52
  let out = "";
46
53
  try {
47
- out = execFileSync("git", ["status", "--porcelain=v1", "-z"], {
54
+ out = boundedChildOutput("git", ["status", "--porcelain=v1", "-z"], {
48
55
  encoding: "utf8",
49
56
  });
50
- } catch {
57
+ } catch (error) {
58
+ // An empty change set means this safety check inspects NOTHING and
59
+ // passes. A busy machine would clear the wiki without reading it.
60
+ rethrowIfChildTimeout(error);
51
61
  return [];
52
62
  }
53
63
  const entries = out.split("\0").filter(Boolean);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.4.6",
3
+ "version": "4.4.7",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -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",