@codyswann/lisa 3.16.0 → 3.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/all/copy-overwrite/scripts/lisa-gates.mjs +1197 -0
- package/all/copy-overwrite/scripts/lisa-reconcile-policy.mjs +1188 -0
- package/all/copy-overwrite/scripts/lisa-run-gates.mjs +597 -0
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +25 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +73 -8
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +6 -2
- package/plugins/lisa/.claude-plugin/plugin.json +10 -1
- package/plugins/lisa/.codex-plugin/hooks.json +9 -0
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- package/plugins/lisa/hooks/secrets-preflight.sh +72 -0
- package/plugins/lisa/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/lisa/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/lisa/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/lisa/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/lisa/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/lisa/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/lisa/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/lisa/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +10 -1
- package/plugins/lisa-copilot/hooks/secrets-preflight.sh +72 -0
- package/plugins/lisa-copilot/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/hooks.json +3 -0
- package/plugins/lisa-cursor/hooks/secrets-preflight.sh +72 -0
- package/plugins/lisa-cursor/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/.claude-plugin/plugin.json +9 -0
- package/plugins/src/base/hooks/secrets-preflight.sh +72 -0
- package/plugins/src/base/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/src/base/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/src/base/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/src/base/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/src/base/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- package/scripts/generate-lisa-owned-hash-ledger.mjs +10 -1
- package/scripts/lib/per-agent-hook-filter.mjs +18 -0
- package/typescript/copy-contents/.husky/pre-commit +130 -21
- package/typescript/copy-contents/.husky/pre-push +202 -22
|
@@ -206,22 +206,11 @@ export function validateSecrets(secrets) {
|
|
|
206
206
|
);
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
continue;
|
|
215
|
-
}
|
|
216
|
-
for (const name of value) {
|
|
217
|
-
if (typeof name !== "string" || !/^[A-Z][A-Z0-9_]*$/.test(name)) {
|
|
218
|
-
problems.push(
|
|
219
|
-
`secrets.${field} entry ${JSON.stringify(name)} is not an exact ` +
|
|
220
|
-
`UPPER_SNAKE_CASE environment-variable name. Lookup is never fuzzy.`
|
|
221
|
-
);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
}
|
|
209
|
+
// `rotating` is a flat list on every surface. `require` also accepts a
|
|
210
|
+
// surface-scoped object, because the required set genuinely differs by where
|
|
211
|
+
// the agent runs — see `resolveRequire` in surfaces.mjs.
|
|
212
|
+
validateKeyList("rotating", secrets.rotating, problems);
|
|
213
|
+
validateRequire(secrets.require, problems);
|
|
225
214
|
|
|
226
215
|
problems.push(...validatePropagating(secrets.propagating));
|
|
227
216
|
|
|
@@ -263,6 +252,73 @@ export function validateSecrets(secrets) {
|
|
|
263
252
|
* @param {object} entry The entry or platform block.
|
|
264
253
|
* @param {string[]} problems Accumulator.
|
|
265
254
|
*/
|
|
255
|
+
/**
|
|
256
|
+
* Whether a value parses as a dotted version the comparator can order.
|
|
257
|
+
* @param {unknown} value Candidate version.
|
|
258
|
+
* @returns {boolean} True for "20", "1.2", "1.2.3" and similar.
|
|
259
|
+
*/
|
|
260
|
+
function isVersionish(value) {
|
|
261
|
+
return typeof value === "string" && /^\d+(\.\d+)*$/.test(value.trim());
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Validate a flat list of exact environment-variable names.
|
|
266
|
+
* @param {string} field Config field name, for messages.
|
|
267
|
+
* @param {unknown} value The declared value.
|
|
268
|
+
* @param {string[]} problems Collector.
|
|
269
|
+
*/
|
|
270
|
+
function validateKeyList(field, value, problems) {
|
|
271
|
+
if (value === undefined || value === null) return;
|
|
272
|
+
if (!Array.isArray(value)) {
|
|
273
|
+
problems.push(`secrets.${field} must be an array of exact key names`);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
for (const name of value) {
|
|
277
|
+
if (typeof name !== "string" || !/^[A-Z][A-Z0-9_]*$/.test(name)) {
|
|
278
|
+
problems.push(
|
|
279
|
+
`secrets.${field} entry ${JSON.stringify(name)} is not an exact ` +
|
|
280
|
+
`UPPER_SNAKE_CASE environment-variable name. Lookup is never fuzzy.`
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Validate `require` in either of its two shapes.
|
|
288
|
+
*
|
|
289
|
+
* An array means every surface. An object scopes per surface, with `all`
|
|
290
|
+
* applying everywhere. Unknown keys are rejected rather than ignored: a typo
|
|
291
|
+
* like `github_actions` for `github-actions` would otherwise resolve to an
|
|
292
|
+
* empty list and silently assert nothing on the one surface it was written
|
|
293
|
+
* for — a declaration that reads as protection and delivers none.
|
|
294
|
+
* @param {unknown} value The declared value.
|
|
295
|
+
* @param {string[]} problems Collector.
|
|
296
|
+
*/
|
|
297
|
+
function validateRequire(value, problems) {
|
|
298
|
+
if (value === undefined || value === null) return;
|
|
299
|
+
if (Array.isArray(value)) {
|
|
300
|
+
validateKeyList("require", value, problems);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (typeof value !== "object") {
|
|
304
|
+
problems.push(
|
|
305
|
+
`secrets.require must be an array of key names, or an object keyed by ` +
|
|
306
|
+
`surface with an optional "all" entry`
|
|
307
|
+
);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
for (const [key, names] of Object.entries(value)) {
|
|
311
|
+
if (key !== "all" && !SURFACES.has(key)) {
|
|
312
|
+
problems.push(
|
|
313
|
+
`secrets.require key "${key}" is not a known surface. ` +
|
|
314
|
+
`Known: all, ${[...SURFACES].join(", ")}.`
|
|
315
|
+
);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
validateKeyList(`require.${key}`, names, problems);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
266
322
|
function validateInstallArtifact(label, entry, problems) {
|
|
267
323
|
if (!INSTALL_METHODS.has(entry.install)) {
|
|
268
324
|
problems.push(
|
|
@@ -308,7 +364,23 @@ export function validateRemoteEnv(remoteEnv) {
|
|
|
308
364
|
const problems = [];
|
|
309
365
|
|
|
310
366
|
for (const tool of remoteEnv.tools?.require ?? []) {
|
|
311
|
-
if (!tool.name)
|
|
367
|
+
if (!tool.name) {
|
|
368
|
+
problems.push("remoteEnv.tools.require entry has no name");
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
// `minVersion` was accepted unvalidated while `install` entries had their
|
|
372
|
+
// shape checked in full. The asymmetry mattered: the value is fed to a
|
|
373
|
+
// version comparison, so `minVersion: "twenty"` sorted below every real
|
|
374
|
+
// version and the requirement silently passed for any installed release —
|
|
375
|
+
// a declared constraint that enforced nothing.
|
|
376
|
+
if (tool.minVersion !== undefined && !isVersionish(tool.minVersion)) {
|
|
377
|
+
problems.push(
|
|
378
|
+
`remoteEnv require "${tool.name}" has minVersion ` +
|
|
379
|
+
`${JSON.stringify(tool.minVersion)}, which is not a dotted version ` +
|
|
380
|
+
`like "20" or "1.2.3". It is compared numerically, so a value that ` +
|
|
381
|
+
`does not parse would accept every installed version.`
|
|
382
|
+
);
|
|
383
|
+
}
|
|
312
384
|
}
|
|
313
385
|
for (const tool of remoteEnv.tools?.install ?? []) {
|
|
314
386
|
if (!tool.name) {
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prove, before an agent starts work, that the CLIs it needs are on PATH.
|
|
3
|
+
*
|
|
4
|
+
* The symmetric half of `preflight-secrets.mjs`, and it exists for the same
|
|
5
|
+
* reason: the logic to answer the question was already good, and nothing asked
|
|
6
|
+
* it at the moment the answer mattered. `planToolchain` decides presence,
|
|
7
|
+
* minimum version, and installability correctly — but its only callers were
|
|
8
|
+
* `/lisa:setup:local-env`, which a human runs deliberately, and container
|
|
9
|
+
* provisioning. An agent on a laptop could claim a ticket, cut a branch, and
|
|
10
|
+
* discover `maestro` was never installed forty minutes later.
|
|
11
|
+
*
|
|
12
|
+
* **This is a caller, not a checker.** Every decision comes from
|
|
13
|
+
* `planToolchain`. Writing a second implementation is exactly the defect this
|
|
14
|
+
* change also fixes elsewhere — `verify-remote-env.mjs` grew its own toolchain
|
|
15
|
+
* check that forgot `minVersion`, so a container verified clean against a node
|
|
16
|
+
* older than the manifest demanded. One function, one verdict, no drift.
|
|
17
|
+
*
|
|
18
|
+
* **Two verdicts, not three.** `preflight-secrets` needs `unreachable` because
|
|
19
|
+
* a vault can fail to be asked. Probing a local binary cannot: it is present or
|
|
20
|
+
* it is not. The analogous trap — a tool whose version cannot be parsed — is
|
|
21
|
+
* already handled correctly upstream, because `planRequired` compares
|
|
22
|
+
* `found.version ?? "0"` and an unknown version loses every `minVersion`
|
|
23
|
+
* comparison. It fails closed without needing a verdict of its own.
|
|
24
|
+
*
|
|
25
|
+
* **The one thing tools have that credentials do not** is a self-service
|
|
26
|
+
* remedy. An `install` entry is pinned and checksummed, so "missing" splits
|
|
27
|
+
* into "Lisa can place this for you" and "you must act". A credential can never
|
|
28
|
+
* be self-provisioned, which is why that distinction has no secrets equivalent.
|
|
29
|
+
* @module preflight-tools
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
33
|
+
import { join } from "node:path";
|
|
34
|
+
import { fileURLToPath } from "node:url";
|
|
35
|
+
|
|
36
|
+
import { probe, readRemoteEnvConfig } from "./setup-remote-env.mjs";
|
|
37
|
+
import { currentPlatform, planToolchain } from "./toolchain.mjs";
|
|
38
|
+
import { toolFloor } from "./tool-floor.mjs";
|
|
39
|
+
|
|
40
|
+
/** Plan actions that mean the agent cannot use the tool right now. */
|
|
41
|
+
const BLOCKING = new Set(["missing", "invalid"]);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Read the whole config, for the derivations that live outside `remoteEnv`.
|
|
45
|
+
*
|
|
46
|
+
* The floor is implied by `tracker`, `secrets.provider` and `quality`, none of
|
|
47
|
+
* which sit under `remoteEnv` — so this reads the root rather than reusing
|
|
48
|
+
* `readRemoteEnvConfig`, which deliberately returns only its own block.
|
|
49
|
+
*
|
|
50
|
+
* **Absent and damaged are not the same answer.** A project with no
|
|
51
|
+
* `.lisa.config.json` declares no tools, and `{}` states that correctly. A file
|
|
52
|
+
* that exists and does not parse states nothing — and returning `{}` for it
|
|
53
|
+
* derives an empty floor, which reports `ok` precisely when the declaration
|
|
54
|
+
* this check exists to enforce could not be read. That is the vacuous green
|
|
55
|
+
* `preflight-secrets` refuses in its own header, so this throws instead, the
|
|
56
|
+
* same way `readConfig` in `surfaces.mjs` does.
|
|
57
|
+
* @param {string} [cwd] Directory to look in.
|
|
58
|
+
* @returns {object} Parsed config root, empty only when the file is absent.
|
|
59
|
+
* @throws {Error} When the file exists but cannot be read or parsed.
|
|
60
|
+
*/
|
|
61
|
+
export function readConfigRoot(cwd = process.cwd()) {
|
|
62
|
+
const path = join(cwd, ".lisa.config.json");
|
|
63
|
+
if (!existsSync(path)) return {};
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
66
|
+
} catch (err) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`${path} is not readable, so the tools it requires could not be ` +
|
|
69
|
+
`derived and nothing was checked: ${err.message}`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Merge the derived floor into a declared manifest.
|
|
76
|
+
*
|
|
77
|
+
* A derived tool that the project already declares is dropped rather than
|
|
78
|
+
* duplicated, and the declaration wins: a project that pinned `minVersion` for
|
|
79
|
+
* `gh` has said something more specific than the derivation knows, and
|
|
80
|
+
* overriding it would discard the more informed statement.
|
|
81
|
+
* @param {{require?: object[], install?: object[]}} tools Declared manifest.
|
|
82
|
+
* @param {Array<{name: string, reason: string}>} floor Derived tools.
|
|
83
|
+
* @returns {{tools: {require: object[], install?: object[]}, reasons: Record<string, string>}} Merged manifest.
|
|
84
|
+
*/
|
|
85
|
+
export function mergeFloor(tools, floor) {
|
|
86
|
+
const declared = new Set([
|
|
87
|
+
...(tools.require ?? []).map(tool => tool.name),
|
|
88
|
+
...(tools.install ?? []).map(tool => tool.name),
|
|
89
|
+
]);
|
|
90
|
+
const added = floor.filter(entry => !declared.has(entry.name));
|
|
91
|
+
const reasons = Object.fromEntries(
|
|
92
|
+
floor.map(entry => [entry.name, entry.reason])
|
|
93
|
+
);
|
|
94
|
+
return {
|
|
95
|
+
tools: {
|
|
96
|
+
...tools,
|
|
97
|
+
require: [
|
|
98
|
+
...(tools.require ?? []),
|
|
99
|
+
...added.map(entry => ({ name: entry.name })),
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
reasons,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Check every required tool against this machine.
|
|
108
|
+
* @param {object} [config] Parsed config root.
|
|
109
|
+
* @param {object} [remoteEnv] Parsed `remoteEnv` block.
|
|
110
|
+
* @param {Function} [versionProbe] Version probe, injected for tests.
|
|
111
|
+
* @param {string} [platform] Platform key, injected for tests.
|
|
112
|
+
* @returns {{verdict: string, blocked: Array<{name: string, action: string, reason: string}>, installable: Array<{name: string, action: string, reason: string}>, reasons: Record<string, string>}}
|
|
113
|
+
*/
|
|
114
|
+
export function preflightTools(
|
|
115
|
+
config = readConfigRoot(),
|
|
116
|
+
remoteEnv = readRemoteEnvConfig(),
|
|
117
|
+
versionProbe = probe,
|
|
118
|
+
platform = currentPlatform()
|
|
119
|
+
) {
|
|
120
|
+
const { tools, reasons } = mergeFloor(
|
|
121
|
+
remoteEnv.tools ?? {},
|
|
122
|
+
toolFloor(config)
|
|
123
|
+
);
|
|
124
|
+
if (!(tools.require ?? []).length && !(tools.install ?? []).length) {
|
|
125
|
+
return { verdict: "ok", blocked: [], installable: [], reasons };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// "local" because this runs where the agent is, and it is the surface word
|
|
129
|
+
// `appliesToSurface` already understands — a tool narrowed to ["remote"] is
|
|
130
|
+
// correctly ignored on a laptop rather than reported as missing there.
|
|
131
|
+
const plan = planToolchain(tools, versionProbe, "local", platform);
|
|
132
|
+
const blocked = plan.filter(step => BLOCKING.has(step.action));
|
|
133
|
+
const installable = plan.filter(step => step.action === "install");
|
|
134
|
+
return {
|
|
135
|
+
verdict: blocked.length || installable.length ? "missing" : "ok",
|
|
136
|
+
blocked,
|
|
137
|
+
installable,
|
|
138
|
+
reasons,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Render a verdict for whoever has to act on it.
|
|
144
|
+
* @param {object} result A {@link preflightTools} result.
|
|
145
|
+
* @returns {string} Operator-readable report, empty when nothing needs saying.
|
|
146
|
+
*/
|
|
147
|
+
export function reportTools(result) {
|
|
148
|
+
if (result.verdict === "ok") return "";
|
|
149
|
+
|
|
150
|
+
// The header tracks the exit code. Only a blocked tool is a failure — one
|
|
151
|
+
// Lisa can install is an action with a command attached, and calling that
|
|
152
|
+
// "FAILED" while exiting zero teaches readers that the word means nothing.
|
|
153
|
+
const lines = [
|
|
154
|
+
result.blocked.length
|
|
155
|
+
? "Tooling preflight FAILED."
|
|
156
|
+
: "Tooling preflight — action available.",
|
|
157
|
+
];
|
|
158
|
+
if (result.installable.length) {
|
|
159
|
+
lines.push(
|
|
160
|
+
``,
|
|
161
|
+
`Lisa can install these itself — they are pinned and checksummed.`,
|
|
162
|
+
`Run /lisa:setup:local-env to place them:`,
|
|
163
|
+
``
|
|
164
|
+
);
|
|
165
|
+
for (const step of result.installable) {
|
|
166
|
+
lines.push(` ${step.name} — ${step.reason}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (result.blocked.length) {
|
|
170
|
+
lines.push(
|
|
171
|
+
``,
|
|
172
|
+
`These need you. Lisa has no pinned artifact it can place:`,
|
|
173
|
+
``
|
|
174
|
+
);
|
|
175
|
+
for (const step of result.blocked) {
|
|
176
|
+
const why = result.reasons[step.name];
|
|
177
|
+
lines.push(` ${step.name} — ${step.reason}`);
|
|
178
|
+
if (why) lines.push(` required because ${why}`);
|
|
179
|
+
}
|
|
180
|
+
lines.push(
|
|
181
|
+
``,
|
|
182
|
+
`Work needing one of these cannot be completed. Route the item to`,
|
|
183
|
+
`blocked with this reason rather than claiming it and stopping partway.`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return lines.join("\n");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* CLI entry point. Prints the report and exits non-zero when anything blocks.
|
|
191
|
+
*/
|
|
192
|
+
function main() {
|
|
193
|
+
let result;
|
|
194
|
+
try {
|
|
195
|
+
result = preflightTools();
|
|
196
|
+
} catch (err) {
|
|
197
|
+
// A config that cannot be read is its own outcome, and it is not "ok". Said
|
|
198
|
+
// in the operator's vocabulary rather than as a stack trace, because this
|
|
199
|
+
// text is what the session-start hook injects for someone to act on.
|
|
200
|
+
console.error(
|
|
201
|
+
[
|
|
202
|
+
`Tooling preflight could NOT be completed.`,
|
|
203
|
+
``,
|
|
204
|
+
` reason: ${err.message}`,
|
|
205
|
+
``,
|
|
206
|
+
`Nothing was checked, so this is not a report that the toolchain is`,
|
|
207
|
+
`fine. Repair the configuration, then start a new session.`,
|
|
208
|
+
].join("\n")
|
|
209
|
+
);
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
const text = reportTools(result);
|
|
213
|
+
if (text) console.error(text);
|
|
214
|
+
if (result.blocked.length) process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Whether this module is the entry point node was asked to run.
|
|
219
|
+
*
|
|
220
|
+
* Both sides are realpath'd rather than compared as text: `import.meta.url` is
|
|
221
|
+
* the resolved path while `process.argv[1]` is whatever the caller typed, so a
|
|
222
|
+
* symlinked path — every git worktree, and every `/tmp` path on macOS — makes a
|
|
223
|
+
* raw comparison false. The module then loads, runs nothing, and exits 0, which
|
|
224
|
+
* is a readiness check reporting clean because it never ran. Same rule and same
|
|
225
|
+
* reasoning as `scripts/lib/invoked-as-script.mjs`, written out here because a
|
|
226
|
+
* plugin payload has no `./lib/` to import from.
|
|
227
|
+
* @param {string} moduleUrl This module's own `import.meta.url`.
|
|
228
|
+
* @param {string} [argv1] Entry path; defaults to `process.argv[1]`.
|
|
229
|
+
* @returns {boolean} Whether the CLI body should run.
|
|
230
|
+
*/
|
|
231
|
+
function invokedAsScript(moduleUrl, argv1 = process.argv[1]) {
|
|
232
|
+
if (!argv1) return false;
|
|
233
|
+
try {
|
|
234
|
+
return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
|
|
235
|
+
} catch {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (invokedAsScript(import.meta.url)) {
|
|
241
|
+
main();
|
|
242
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive the CLIs a project's own configuration already implies.
|
|
3
|
+
*
|
|
4
|
+
* The symmetric half of `routing-floor.mjs`. A credential and the binary that
|
|
5
|
+
* consumes it are the same question asked twice — can this agent do the work —
|
|
6
|
+
* and both were answered only by a hand-maintained list.
|
|
7
|
+
*
|
|
8
|
+
* `detect-tooling` already knows most of these pairings and states them well:
|
|
9
|
+
* "the work-item guardrails shell out to `gh` for every tracker read". But it
|
|
10
|
+
* knows them at *proposal* time, as suggestions a human runs a skill to see and
|
|
11
|
+
* then copies into config. Nothing derives them at runtime, so a project that
|
|
12
|
+
* never ran the skill — or ran it before it adopted Maestro — has a manifest
|
|
13
|
+
* that silently understates what its agents need.
|
|
14
|
+
*
|
|
15
|
+
* This promotes those pairings to a derivation. The floor is unioned into the
|
|
16
|
+
* required set, never written into config, so it cannot drift from the routing
|
|
17
|
+
* that produces it.
|
|
18
|
+
*
|
|
19
|
+
* Deliberately narrow. An entry belongs here only when the configuration that
|
|
20
|
+
* implies it makes the tool unavoidable — not merely likely. A project can have
|
|
21
|
+
* `quality.testCoverage` thresholds and run them through any runner, so no CLI
|
|
22
|
+
* is derived from that; `secrets.provider: "bitwarden"` on the other hand
|
|
23
|
+
* cannot resolve one credential without `bws`.
|
|
24
|
+
* @module tool-floor
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Config predicates that make a CLI unavoidable, with the reason to report.
|
|
29
|
+
*
|
|
30
|
+
* The reason is not decoration. A missing tool has two valid remedies — install
|
|
31
|
+
* it, or change the configuration that demands it — and an operator cannot pick
|
|
32
|
+
* between them without knowing which line of config is responsible.
|
|
33
|
+
*/
|
|
34
|
+
const DERIVATIONS = [
|
|
35
|
+
{
|
|
36
|
+
tool: "gh",
|
|
37
|
+
applies: cfg => cfg.tracker === "github" || cfg.source === "github",
|
|
38
|
+
reason: cfg =>
|
|
39
|
+
`${cfg.tracker === "github" ? "tracker" : "source"} is "github", and ` +
|
|
40
|
+
`the work-item guardrails shell out to gh for every tracker read`,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
tool: "bws",
|
|
44
|
+
applies: cfg => cfg.secrets?.provider === "bitwarden",
|
|
45
|
+
reason: () =>
|
|
46
|
+
`secrets.provider is "bitwarden", and the CLI is how every secret is ` +
|
|
47
|
+
`resolved`,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
tool: "doppler",
|
|
51
|
+
applies: cfg => cfg.secrets?.provider === "doppler",
|
|
52
|
+
reason: () => `secrets.provider is "doppler"`,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
tool: "maestro",
|
|
56
|
+
// Keyed on the coverage block rather than a `.maestro` directory: config is
|
|
57
|
+
// the declaration that this project gates on Maestro, where a stray
|
|
58
|
+
// directory could be a leftover. `detect-tooling` uses the directory
|
|
59
|
+
// because it is guessing; here the project has already said so.
|
|
60
|
+
applies: cfg => cfg.quality?.e2eCoverage?.maestro !== undefined,
|
|
61
|
+
reason: () =>
|
|
62
|
+
`quality.e2eCoverage.maestro is configured, and nothing installs the ` +
|
|
63
|
+
`maestro binary`,
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The CLIs a project's configuration makes mandatory.
|
|
69
|
+
* @param {object} [config] Parsed `.lisa.config.json` root.
|
|
70
|
+
* @returns {Array<{name: string, reason: string}>} Sorted derived tools.
|
|
71
|
+
*/
|
|
72
|
+
export function toolFloor(config = {}) {
|
|
73
|
+
return DERIVATIONS.filter(entry => safely(() => entry.applies(config)))
|
|
74
|
+
.map(entry => ({ name: entry.tool, reason: entry.reason(config) }))
|
|
75
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Evaluate a predicate, treating a malformed config as "does not apply".
|
|
80
|
+
*
|
|
81
|
+
* A config shaped unexpectedly is a problem for the schema validator to report
|
|
82
|
+
* in its own vocabulary. Throwing here would make a readiness check the place a
|
|
83
|
+
* structural defect first surfaces, with a message about tools that names
|
|
84
|
+
* nothing an operator can act on.
|
|
85
|
+
* @param {Function} predicate Test to run.
|
|
86
|
+
* @returns {boolean} The result, or false when it threw.
|
|
87
|
+
*/
|
|
88
|
+
function safely(predicate) {
|
|
89
|
+
try {
|
|
90
|
+
return Boolean(predicate());
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -25,8 +25,8 @@ import { execFileSync } from "node:child_process";
|
|
|
25
25
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
26
26
|
import { join } from "node:path";
|
|
27
27
|
|
|
28
|
-
import { readRemoteEnvConfig } from "./setup-remote-env.mjs";
|
|
29
|
-
import {
|
|
28
|
+
import { probe, readRemoteEnvConfig } from "./setup-remote-env.mjs";
|
|
29
|
+
import { planToolchain } from "./toolchain.mjs";
|
|
30
30
|
|
|
31
31
|
/** Collected results, so every check runs before anything reports failure. */
|
|
32
32
|
const results = [];
|
|
@@ -84,39 +84,21 @@ function node(args) {
|
|
|
84
84
|
|
|
85
85
|
/**
|
|
86
86
|
* Assert each declared tool is present at its pinned or minimum version.
|
|
87
|
+
*
|
|
88
|
+
* Delegates every decision to `planToolchain` rather than probing here. This
|
|
89
|
+
* function used to run its own loop, and the copy silently forgot `minVersion`:
|
|
90
|
+
* a `require` entry passed as long as the binary answered `--version` at all,
|
|
91
|
+
* so a container verified clean against a node older than the manifest
|
|
92
|
+
* demanded. The plan-side check rejected exactly what this one accepted.
|
|
93
|
+
*
|
|
94
|
+
* That is the same failure the note validators had before one shared module
|
|
95
|
+
* replaced them, and it recurs for the same reason — two implementations of one
|
|
96
|
+
* question cannot be kept in step by intention. There is now one.
|
|
87
97
|
* @param {object} tools Toolchain manifest.
|
|
88
98
|
*/
|
|
89
99
|
function verifyToolchain(tools) {
|
|
90
|
-
for (const
|
|
91
|
-
|
|
92
|
-
const out = execFileSync(tool.name, ["--version"], {
|
|
93
|
-
encoding: "utf8",
|
|
94
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
95
|
-
});
|
|
96
|
-
check(true, `tool ${tool.name}`, extractVersion(out) ?? "present");
|
|
97
|
-
} catch {
|
|
98
|
-
check(false, `tool ${tool.name}`, "required but not present");
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
for (const tool of tools.install ?? []) {
|
|
102
|
-
try {
|
|
103
|
-
const out = execFileSync(tool.name, ["--version"], {
|
|
104
|
-
encoding: "utf8",
|
|
105
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
106
|
-
});
|
|
107
|
-
const found = extractVersion(out);
|
|
108
|
-
check(
|
|
109
|
-
found === tool.version,
|
|
110
|
-
`tool ${tool.name}`,
|
|
111
|
-
`${found} (pin ${tool.version})`
|
|
112
|
-
);
|
|
113
|
-
} catch {
|
|
114
|
-
check(
|
|
115
|
-
false,
|
|
116
|
-
`tool ${tool.name}`,
|
|
117
|
-
`pinned ${tool.version} but not installed`
|
|
118
|
-
);
|
|
119
|
-
}
|
|
100
|
+
for (const step of planToolchain(tools, probe, "remote")) {
|
|
101
|
+
check(step.action === "present", `tool ${step.name}`, step.reason);
|
|
120
102
|
}
|
|
121
103
|
}
|
|
122
104
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Prove at session start that this agent can actually do the work: that the
|
|
3
|
+
# credentials the project needs resolve, and that the CLIs it needs are on PATH.
|
|
4
|
+
#
|
|
5
|
+
# One hook for both because it is one question — can this agent do the work —
|
|
6
|
+
# and an operator reading two separate reports has to assemble the answer
|
|
7
|
+
# themselves. Both halves also share the same failure mode if split: two hooks
|
|
8
|
+
# means two subprocess spawns on every session start, for a check that is silent
|
|
9
|
+
# almost every time.
|
|
10
|
+
#
|
|
11
|
+
# Injects rather than blocks, and that is deliberate. The right response to a
|
|
12
|
+
# missing credential or tool is for the agent to know before it claims work and
|
|
13
|
+
# to route the item to blocked with a reason a human can act on — not for the
|
|
14
|
+
# session to die. A session that cannot reach the vault can still write code,
|
|
15
|
+
# review a PR, or answer a question; killing it would tax every session for a
|
|
16
|
+
# minority need, and a control people route around enforces nothing.
|
|
17
|
+
#
|
|
18
|
+
# The hard gates live where they can afford to be hard: `lisa doctor` and CI
|
|
19
|
+
# run the same two scripts and exit non-zero on them.
|
|
20
|
+
#
|
|
21
|
+
# Silent on success on purpose. A control that announces itself when everything
|
|
22
|
+
# is fine trains its readers to skim past it, and then it is decoration.
|
|
23
|
+
set -uo pipefail
|
|
24
|
+
|
|
25
|
+
INPUT=$(cat 2>/dev/null || true)
|
|
26
|
+
if [ -n "$INPUT" ]; then
|
|
27
|
+
HOOK_EVENT=$(printf '%s' "$INPUT" | jq -r '.hook_event_name // "SessionStart"' 2>/dev/null || echo "SessionStart")
|
|
28
|
+
else
|
|
29
|
+
HOOK_EVENT="SessionStart"
|
|
30
|
+
fi
|
|
31
|
+
|
|
32
|
+
ROOT="${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}}"
|
|
33
|
+
SECRETS="$ROOT/skills/lisa-secrets-access/scripts/preflight-secrets.mjs"
|
|
34
|
+
TOOLS="$ROOT/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs"
|
|
35
|
+
|
|
36
|
+
command -v node >/dev/null 2>&1 || exit 0
|
|
37
|
+
# The hook's whole output is one `jq -n` document. Without jq there is nothing
|
|
38
|
+
# to write, so exiting is the only honest outcome — the alternative is a hook
|
|
39
|
+
# that prints `jq: command not found` and exits 127, which reports a failure
|
|
40
|
+
# from a check designed never to block. The doctor and CI gates run the same
|
|
41
|
+
# two scripts without jq involved.
|
|
42
|
+
command -v jq >/dev/null 2>&1 || exit 0
|
|
43
|
+
|
|
44
|
+
# stderr carries each report; stdout is reserved for the hook's JSON. A script
|
|
45
|
+
# that is absent (partial or older install) or fails to run at all contributes
|
|
46
|
+
# nothing, because a broken hook must not manufacture a finding.
|
|
47
|
+
REPORT=""
|
|
48
|
+
for script in "$SECRETS" "$TOOLS"; do
|
|
49
|
+
[ -f "$script" ] || continue
|
|
50
|
+
# An escape hatch that is honest about what it costs, and scoped to what it
|
|
51
|
+
# names. Set when a surface has no secrets provider by design and the noise is
|
|
52
|
+
# not worth it; the doctor and CI gates are unaffected, so opting out here
|
|
53
|
+
# does not opt out of the check entirely. It must not also silence the
|
|
54
|
+
# TOOLING half — a variable about secrets that hides a missing `gh` or
|
|
55
|
+
# `maestro` is an opt-out from a check nobody opted out of.
|
|
56
|
+
if [ "$script" = "$SECRETS" ] && [ "${LISA_SKIP_SECRETS_PREFLIGHT:-}" = "1" ]; then
|
|
57
|
+
continue
|
|
58
|
+
fi
|
|
59
|
+
OUT=$(node "$script" 2>&1 >/dev/null) || true
|
|
60
|
+
[ -n "$OUT" ] || continue
|
|
61
|
+
[ -n "$REPORT" ] && REPORT+=$'\n\n'
|
|
62
|
+
REPORT+="$OUT"
|
|
63
|
+
done
|
|
64
|
+
|
|
65
|
+
[ -n "$REPORT" ] || exit 0
|
|
66
|
+
|
|
67
|
+
CONTEXT="## Readiness preflight
|
|
68
|
+
|
|
69
|
+
$REPORT"
|
|
70
|
+
|
|
71
|
+
jq -n --arg event "$HOOK_EVENT" --arg ctx "$CONTEXT" \
|
|
72
|
+
'{"hookSpecificOutput": {"hookEventName": $event, "additionalContext": $ctx}}'
|