@codyswann/lisa 2.324.0 → 2.325.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/all/copy-overwrite/scripts/lisa-enforcement-fallback.sh +71 -0
  2. package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +102 -0
  3. package/all/copy-overwrite/scripts/lisa-hooks/block-shell-json-parsing.sh +220 -0
  4. package/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net.sh +562 -0
  5. package/all/merge/.claude/settings.json +19 -4
  6. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  7. package/dist/core/upstream-evidence-manifest.js +42 -9
  8. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  9. package/dist/standards/git-state.d.ts.map +1 -1
  10. package/dist/standards/git-state.js +10 -1
  11. package/dist/standards/git-state.js.map +1 -1
  12. package/package.json +1 -1
  13. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  14. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  15. package/plugins/lisa/.codex-plugin/skills/lisa-detect-tooling/SKILL.md +60 -0
  16. package/plugins/lisa/.codex-plugin/skills/lisa-detect-tooling/agents/openai.yaml +4 -0
  17. package/plugins/lisa/.codex-plugin/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +311 -0
  18. package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/SKILL.md +18 -2
  19. package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +74 -3
  20. package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/toolchain.mjs +50 -3
  21. package/plugins/lisa/commands/detect-tooling.md +7 -0
  22. package/plugins/lisa/rules/eager/tracked-work.md +1 -1
  23. package/plugins/lisa/rules/reference/tracked-work.md +1 -1
  24. package/plugins/lisa/skills/lisa-detect-tooling/SKILL.md +60 -0
  25. package/plugins/lisa/skills/lisa-detect-tooling/agents/openai.yaml +4 -0
  26. package/plugins/lisa/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +311 -0
  27. package/plugins/lisa/skills/lisa-setup-remote-env/SKILL.md +18 -2
  28. package/plugins/lisa/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +74 -3
  29. package/plugins/lisa/skills/lisa-setup-remote-env/scripts/toolchain.mjs +50 -3
  30. package/plugins/lisa-agy/commands/lisa/detect-tooling.md +7 -0
  31. package/plugins/lisa-agy/plugin.json +1 -1
  32. package/plugins/lisa-agy/skills/lisa-detect-tooling/SKILL.md +60 -0
  33. package/plugins/lisa-agy/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +311 -0
  34. package/plugins/lisa-agy/skills/lisa-setup-remote-env/SKILL.md +18 -2
  35. package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +74 -3
  36. package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/toolchain.mjs +50 -3
  37. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  39. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  40. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-copilot/commands/lisa/detect-tooling.md +7 -0
  44. package/plugins/lisa-copilot/rules/eager/tracked-work.md +1 -1
  45. package/plugins/lisa-copilot/rules/reference/tracked-work.md +1 -1
  46. package/plugins/lisa-copilot/skills/lisa-detect-tooling/SKILL.md +60 -0
  47. package/plugins/lisa-copilot/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +311 -0
  48. package/plugins/lisa-copilot/skills/lisa-setup-remote-env/SKILL.md +18 -2
  49. package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +74 -3
  50. package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/toolchain.mjs +50 -3
  51. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-cursor/commands/lisa/detect-tooling.md +7 -0
  53. package/plugins/lisa-cursor/rules/tracked-work-reference.mdc +1 -1
  54. package/plugins/lisa-cursor/rules/tracked-work.mdc +1 -1
  55. package/plugins/lisa-cursor/skills/lisa-detect-tooling/SKILL.md +60 -0
  56. package/plugins/lisa-cursor/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +311 -0
  57. package/plugins/lisa-cursor/skills/lisa-setup-remote-env/SKILL.md +18 -2
  58. package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +74 -3
  59. package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/toolchain.mjs +50 -3
  60. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  62. package/plugins/lisa-expo-agy/plugin.json +1 -1
  63. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  67. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  68. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  72. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  73. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  77. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  78. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  82. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  83. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  87. package/plugins/lisa-rails-agy/plugin.json +1 -1
  88. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  89. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  90. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  92. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  93. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  94. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  95. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  96. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  97. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  98. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  99. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  100. package/plugins/src/base/commands/detect-tooling.md +7 -0
  101. package/plugins/src/base/rules/eager/tracked-work.md +1 -1
  102. package/plugins/src/base/rules/reference/tracked-work.md +1 -1
  103. package/plugins/src/base/skills/lisa-detect-tooling/SKILL.md +60 -0
  104. package/plugins/src/base/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +311 -0
  105. package/plugins/src/base/skills/lisa-setup-remote-env/SKILL.md +18 -2
  106. package/plugins/src/base/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +74 -3
  107. package/plugins/src/base/skills/lisa-setup-remote-env/scripts/toolchain.mjs +50 -3
  108. package/scripts/build-plugins.sh +32 -0
  109. package/scripts/generate-upstream-evidence-manifest.mjs +50 -11
  110. package/scripts/lisa-enforcement-fallback.sh +16 -2
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.324.0",
3
+ "version": "2.325.0",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.324.0",
3
+ "version": "2.325.0",
4
4
  "description": "AWS CDK-specific Lisa plugin.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.324.0",
3
+ "version": "2.325.0",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.324.0",
3
+ "version": "2.325.0",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.324.0",
3
+ "version": "2.325.0",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.324.0",
3
+ "version": "2.325.0",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -0,0 +1,7 @@
1
+ ---
2
+ description: "Find the command-line tools this project needs but never declares, and propose pinned manifest entries for them. Reads npm scripts, MCP servers, credential usage notes and quality configuration, subtracts what remoteEnv.tools already covers, and prints proposals with evidence. Writes nothing and installs nothing — a tool reaches a machine only once a human has reviewed a pinned, checksummed entry."
3
+ allowed-tools: ["Skill"]
4
+ argument-hint: "[--json]"
5
+ ---
6
+
7
+ Use the /lisa-detect-tooling skill to find undeclared command-line tooling and propose manifest entries for it. $ARGUMENTS
@@ -2,6 +2,6 @@
2
2
 
3
3
  Before the first durable project mutation (code, tests, config, docs, committed research/plans/findings, commits, or PRs), establish exactly one live tracker leaf through `lisa-track`. Read-only discussion and orientation are exempt only while they produce no durable artifact.
4
4
 
5
- The mandatory order is: live-validate an explicit ref, or conservatively search and create exactly one valid leaf through `lisa-tracker-write` when no unique match exists; idempotently claim it through `lisa-tracker-claim`; then persist and verify the worktree-local binding with `node scripts/lisa-work-item.mjs bind <ref>`. Any tracker, claim, or binding failure blocks durable work.
5
+ The mandatory order is: live-validate an explicit ref, or conservatively search and create exactly one valid leaf through `lisa-tracker-write` when no unique match exists; idempotently claim it through `lisa-tracker-claim`; then persist and verify the worktree-local binding with `node scripts/lisa-work-item.mjs bind <ref>`. A tracker that answers no, and any claim or binding failure, blocks durable work; a tracker that cannot be reached does not block a commit — the offline checks still run, the skip is loud on stderr, and the required `Work-Item Traceability` check re-runs the live checks with credentials before anything merges.
6
6
 
7
7
  Carry that canonical ref through the branch, every ordinary commit's `Work-Item:` trailer, the PR, usage/evidence, and `lisa-tracker-sync`. Hooks and CI never create tickets. Keep the binding through interruptions or blocked outcomes; run `node scripts/lisa-work-item.mjs clear` only after merge/deploy/verification, two-way linkage/evidence, and the tracker item have all reached true terminal completion.
@@ -11,7 +11,7 @@ Use `lisa-track` as the single entry point:
11
11
  3. Idempotently claim the resolved leaf through `lisa-tracker-claim`, which reuses the vendor build-intake claim semantics and post-read verifies the claimed-or-later state.
12
12
  4. Before any durable repository work, persist the canonical reference with `node scripts/lisa-work-item.mjs bind <ref>` and verify the worktree-local binding.
13
13
 
14
- The sequence is strict: **live validate/create -> claim -> bind -> durable work**. A failed tracker read/write, claim, or binding blocks the work. Tracker availability must be proven; tool presence or stale session text is not access.
14
+ The sequence is strict: **live validate/create -> claim -> bind -> durable work**. A tracker answer of no, and any claim or binding failure, blocks the work. A tracker that cannot be reached `gh` absent, or its credential refused — does not block a commit: the offline checks still run, the skip is loud on stderr, and the required `Work-Item Traceability` check re-runs the live checks with credentials before anything merges. Any other tracker failure still blocks. Tool presence or stale session text is not access.
15
15
 
16
16
  ## One canonical identity
17
17
 
@@ -0,0 +1,60 @@
1
+ ---
2
+ name: lisa-detect-tooling
3
+ description: "Find the command-line tools a project needs but never declares, and propose pinned manifest entries for them. Reads npm scripts, MCP servers, credential usage notes and quality configuration, subtracts whatever remoteEnv.tools already covers, and prints proposals with evidence. Writes nothing and installs nothing — a tool reaches a machine only when a human has reviewed a pinned, checksummed entry. Invoked by lisa-setup-remote-env before provisioning, and runnable on its own."
4
+ allowed-tools: ["Bash", "Read", "AskUserQuestion"]
5
+ ---
6
+
7
+ # Detect Tooling
8
+
9
+ Lisa provisions tooling through four unrelated mechanisms, and only one of them puts a binary on `PATH`:
10
+
11
+ | tool | how it arrives | what puts it on PATH |
12
+ | --- | --- | --- |
13
+ | playwright, stryker | npm devDependency from a stack template | nothing — a local `node_modules` binary |
14
+ | maestro | npm **scripts** in the Expo template | **nothing** |
15
+ | sonar | `src/sonar/sonar-installer.ts` | nothing |
16
+ | linear | MCP server | nothing, and it needs browser OAuth |
17
+ | bws, gh | `remoteEnv.tools` — pinned and checksummed | this, and only this |
18
+
19
+ Nothing populates that last row. So a project can ship scripts that invoke `maestro`, wire an MCP server whose CLI it also shells out to, and configure Playwright thresholds, while the manifest that actually provisions binaries stays empty — and every one of those fails at the moment of use rather than at setup.
20
+
21
+ That is the same failure this repository has now paid for twice: `gh` was declared nowhere and a cloud session could not commit; `tar` was needed by an install method and asserted by nothing.
22
+
23
+ ## What it does
24
+
25
+ Reads four signals, subtracts what `remoteEnv.tools` already declares, and prints what is left with the evidence for each:
26
+
27
+ - **npm scripts** that invoke a binary. The strongest signal there is — a script running `maestro test` is the project stating a dependency in executable form. Matched on the script *body*, not its name, because the name is a label.
28
+ - **MCP servers with a CLI equivalent.** An MCP server is not a substitute for the binary: several authenticate by browser OAuth, which a container cannot do, so a project relying on one remotely has *no* integration rather than a degraded one.
29
+ - **Credential usage notes.** A note explaining what a token is for usually names the program that consumes it. `lisa-secrets-access` already exposes these without touching a value, which makes them a first-class input rather than a trick.
30
+ - **Quality configuration**, where a threshold implies the tool that produces it.
31
+
32
+ ## What it will not do
33
+
34
+ **It writes nothing and installs nothing.** Output is a proposal with `<pin>`, `<release url>` and `<sha256>` left for a human.
35
+
36
+ That boundary is the whole design. A tool should reach a machine because someone reviewed a pinned entry with a checksum, never because a detector was confident. Detection is evidence; the manifest is the decision. An auto-writing detector would quietly become a second, unreviewed install path — exactly what `assertPinned` exists to prevent.
37
+
38
+ ## Surfaces
39
+
40
+ One declaration per tool, with an optional `surfaces` list, rather than separate blocks per surface. Most tools are needed *everywhere* — a Maestro or Sonar CLI is as required on a laptop as in a container — and duplicated blocks drift.
41
+
42
+ What genuinely differs is **consent**, not the list:
43
+
44
+ - A remote container is disposable and nobody is watching, so it provisions silently.
45
+ - A developer machine belongs to a person, so `--phase=toolchain` reports what is missing and installs nothing unless `--install-tools` is passed.
46
+
47
+ Omitting `surfaces` means every surface, because that is true of most tools and the cost of forgetting should be a redundant check rather than a silent absence.
48
+
49
+ A platform-specific pin is the exception worth knowing: a Linux release archive must be `surfaces: ["remote"]`, with a matching `require` entry for `local`, so a laptop asserts the tool without being offered a binary it cannot run.
50
+
51
+ ## Usage
52
+
53
+ ```sh
54
+ node scripts/detect-tooling.mjs # human-readable proposals
55
+ node scripts/detect-tooling.mjs --json # machine-readable
56
+ ```
57
+
58
+ Confirm each proposal with the operator, then hand it to them to apply. **This skill does not hold `Edit`**, deliberately: a skill that both decides a tool is needed and writes the entry that installs it is the second unreviewed install path this design exists to avoid. Granting `Edit` while documenting "writes nothing" would have made the prose the only thing stopping it.
59
+
60
+ When a proposal is wrong — the tool is genuinely unused, or arrives another way — say so and move on. A rejected proposal is a normal outcome, not a failure.
@@ -0,0 +1,311 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Find the command-line tools a project needs but never declares.
4
+ *
5
+ * Lisa provisions tooling through several unrelated mechanisms — npm
6
+ * devDependencies from a stack template, MCP servers, a dedicated installer for
7
+ * Sonar, and the pinned `remoteEnv.tools` manifest. Only the last one puts a
8
+ * binary on PATH, and nothing populates it. So a project can ship npm scripts
9
+ * that invoke `maestro`, wire an MCP server whose CLI it also shells out to, and
10
+ * declare Playwright coverage thresholds, while the manifest that actually
11
+ * provisions binaries stays empty and every one of those fails at the moment of
12
+ * use rather than at setup.
13
+ *
14
+ * This program only ever *proposes*. It writes nothing, installs nothing, and
15
+ * emits manifest entries with the fields a human still has to fill — a pinned
16
+ * version, a URL, a checksum. That boundary is the point: a tool should reach a
17
+ * machine because someone reviewed a pinned entry, never because a detector was
18
+ * confident. Detection is evidence; the manifest is the decision.
19
+ *
20
+ * Usage:
21
+ * detect-tooling.mjs [--json]
22
+ * @module detect-tooling
23
+ */
24
+
25
+ import { existsSync, readFileSync } from "node:fs";
26
+ import { join } from "node:path";
27
+
28
+ /**
29
+ * Tools Lisa or its templates invoke, and how to recognise a need for them.
30
+ *
31
+ * Deliberately conservative. A false positive costs a human a moment's review;
32
+ * a false negative is the failure this exists to prevent, so the signals are
33
+ * ones that only appear when the tool is genuinely used.
34
+ */
35
+ const KNOWN_TOOLS = Object.freeze({
36
+ maestro: {
37
+ why: "Expo's template ships npm scripts that invoke `maestro`, and nothing installs the binary.",
38
+ mcpFallback: "maestro",
39
+ },
40
+ gh: {
41
+ why: "The work-item guardrails shell out to `gh` for every tracker read.",
42
+ },
43
+ bws: {
44
+ why: "The Bitwarden CLI is how every secret is resolved.",
45
+ },
46
+ "sonar-scanner": {
47
+ why: "Sonar analysis runs a scanner binary, which the Sonar installer configures but does not place on PATH.",
48
+ },
49
+ playwright: {
50
+ why: "Playwright drives browser E2E and its coverage thresholds are configured.",
51
+ viaNpm: "@playwright/test",
52
+ },
53
+ linear: {
54
+ why: "Linear is wired as an MCP server, which needs browser OAuth and so cannot authenticate in a container.",
55
+ mcpFallback: "linear-server",
56
+ },
57
+ });
58
+
59
+ /**
60
+ * Read a JSON file, treating absence or damage as "no signal".
61
+ *
62
+ * A detector that throws on a malformed config is worse than one that misses a
63
+ * signal: it blocks the very command an operator runs to find out what is wrong.
64
+ * @param {string} path File to read.
65
+ * @returns {object|null} Parsed contents, or null.
66
+ */
67
+ function readJson(path) {
68
+ if (!existsSync(path)) return null;
69
+ try {
70
+ return JSON.parse(readFileSync(path, "utf8"));
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Tools named by npm scripts, which is the strongest signal there is.
78
+ *
79
+ * A script that runs `maestro test` is a project stating it needs Maestro, in
80
+ * executable form. Word-boundary matched so `maestro:test` as a script *name*
81
+ * does not count while `maestro test` as its body does — the name is a label,
82
+ * the body is a dependency.
83
+ * @param {object|null} pkg Parsed package.json.
84
+ * @returns {Map<string, string>} Tool name to the script that proves it.
85
+ */
86
+ export function toolsFromScripts(pkg) {
87
+ const found = new Map();
88
+ const scripts = pkg?.scripts ?? {};
89
+ for (const [name, body] of Object.entries(scripts)) {
90
+ if (typeof body !== "string") continue;
91
+ for (const tool of Object.keys(KNOWN_TOOLS)) {
92
+ const pattern = new RegExp(`(^|[\\s;&|(])${tool}(\\s|$)`, "u");
93
+ if (pattern.test(body) && !found.has(tool)) {
94
+ found.set(tool, `npm script "${name}": ${body.trim().slice(0, 60)}`);
95
+ }
96
+ }
97
+ }
98
+ return found;
99
+ }
100
+
101
+ /**
102
+ * Tools implied by MCP servers that also have a CLI.
103
+ *
104
+ * An MCP server is not a substitute for the binary. Several authenticate by
105
+ * browser OAuth, which a container cannot do at all, so a project relying on one
106
+ * remotely has no integration rather than a degraded one — the CLI is the form
107
+ * that survives the trip.
108
+ * @param {object|null} mcp Parsed .mcp.json.
109
+ * @returns {Map<string, string>} Tool name to the server that implies it.
110
+ */
111
+ export function toolsFromMcp(mcp) {
112
+ const found = new Map();
113
+ const servers = Object.keys(mcp?.mcpServers ?? {});
114
+ for (const [tool, meta] of Object.entries(KNOWN_TOOLS)) {
115
+ if (!meta.mcpFallback) continue;
116
+ const server = servers.find(name => name.includes(meta.mcpFallback));
117
+ if (server) {
118
+ found.set(
119
+ tool,
120
+ `MCP server "${server}" (browser OAuth cannot run remotely)`
121
+ );
122
+ }
123
+ }
124
+ return found;
125
+ }
126
+
127
+ /**
128
+ * Tools named in credential usage notes.
129
+ *
130
+ * A note explaining what a token is *for* usually names the program that
131
+ * consumes it, and those notes are already readable without touching a value —
132
+ * `read-secret-note.mjs` exists precisely so an agent can learn a credential's
133
+ * blast radius safely. That makes them a first-class detection input rather than
134
+ * a clever trick.
135
+ * @param {object|null} notes Parsed secret-notes.json.
136
+ * @returns {Map<string, string>} Tool name to the credential that names it.
137
+ */
138
+ export function toolsFromSecretNotes(notes) {
139
+ const found = new Map();
140
+ const entries = Object.entries(notes?.notes ?? notes ?? {});
141
+ for (const [secret, note] of entries) {
142
+ const text = typeof note === "string" ? note : JSON.stringify(note ?? "");
143
+ for (const tool of Object.keys(KNOWN_TOOLS)) {
144
+ const pattern = new RegExp(
145
+ `(^|[^A-Za-z0-9-])${tool}([^A-Za-z0-9-]|$)`,
146
+ "iu"
147
+ );
148
+ if (pattern.test(text) && !found.has(tool)) {
149
+ found.set(tool, `usage note on credential "${secret}"`);
150
+ }
151
+ }
152
+ }
153
+ return found;
154
+ }
155
+
156
+ /**
157
+ * Tools implied by quality configuration.
158
+ * @param {object|null} config Parsed .lisa.config.json.
159
+ * @returns {Map<string, string>} Tool name to the setting that implies it.
160
+ */
161
+ export function toolsFromQuality(config) {
162
+ const found = new Map();
163
+ if (config?.quality?.e2eCoverage?.playwright) {
164
+ found.set("playwright", "quality.e2eCoverage.playwright is configured");
165
+ }
166
+ if (config?.quality?.sonar || config?.sonar) {
167
+ found.set("sonar-scanner", "Sonar analysis is configured");
168
+ }
169
+ return found;
170
+ }
171
+
172
+ /**
173
+ * Everything already declared, by name, so proposals exclude them.
174
+ * @param {object|null} config Parsed .lisa.config.json.
175
+ * @returns {Set<string>} Declared tool names.
176
+ */
177
+ export function declaredTools(config, surface = "remote") {
178
+ const tools = config?.remoteEnv?.tools ?? {};
179
+ const applies = tool => {
180
+ const surfaces = tool.surfaces;
181
+ if (!Array.isArray(surfaces) || surfaces.length === 0) return true;
182
+ return surfaces.includes(surface);
183
+ };
184
+ // Filtered by surface, because a declaration that applies somewhere else is
185
+ // not coverage here. A local-only `require` entry would otherwise suppress
186
+ // the proposal for a tool genuinely missing from the remote manifest — the
187
+ // detector reporting "already declared" about the one surface where it is
188
+ // not.
189
+ return new Set(
190
+ [...(tools.require ?? []), ...(tools.install ?? [])]
191
+ .filter(applies)
192
+ .map(tool => tool.name)
193
+ );
194
+ }
195
+
196
+ /**
197
+ * Collect every signal and subtract what the manifest already covers.
198
+ * @param {string} [cwd] Project root.
199
+ * @returns {Array<{name: string, why: string, evidence: string[]}>} Proposals.
200
+ */
201
+ export function detectTooling(cwd = process.cwd()) {
202
+ const config = readJson(join(cwd, ".lisa.config.json"));
203
+ const pkg = readJson(join(cwd, "package.json"));
204
+ const mcp = readJson(join(cwd, ".mcp.json"));
205
+ const notes = readJson(join(cwd, ".lisa", "secret-notes.json"));
206
+
207
+ const declared = declaredTools(config);
208
+ const signals = [
209
+ toolsFromScripts(pkg),
210
+ toolsFromMcp(mcp),
211
+ toolsFromSecretNotes(notes),
212
+ toolsFromQuality(config),
213
+ ];
214
+
215
+ const merged = new Map();
216
+ for (const signal of signals) {
217
+ for (const [tool, evidence] of signal) {
218
+ if (declared.has(tool)) continue;
219
+ const entry = merged.get(tool) ?? { evidence: [] };
220
+ entry.evidence.push(evidence);
221
+ merged.set(tool, entry);
222
+ }
223
+ }
224
+
225
+ return [...merged.entries()]
226
+ .map(([name, entry]) => ({
227
+ name,
228
+ why: KNOWN_TOOLS[name]?.why ?? "",
229
+ evidence: entry.evidence,
230
+ }))
231
+ .sort((a, b) => a.name.localeCompare(b.name));
232
+ }
233
+
234
+ /**
235
+ * The manifest entries an operator can paste, with the parts only they supply.
236
+ *
237
+ * Returns both lists because a platform-specific archive needs both: an
238
+ * `install` entry for the surface it is built for, and a `require` entry so the
239
+ * other surface still asserts the tool instead of silently ignoring it.
240
+ * @param {{name: string}} proposal One detected tool.
241
+ * @returns {{install: object[], require: object[]}} Manifest skeletons.
242
+ */
243
+ export function proposedEntries(proposal) {
244
+ const viaNpm = KNOWN_TOOLS[proposal.name]?.viaNpm;
245
+ if (viaNpm) {
246
+ // npm resolves per platform, so one entry serves every surface.
247
+ return {
248
+ install: [
249
+ {
250
+ name: proposal.name,
251
+ install: "npm-global",
252
+ package: viaNpm,
253
+ version: "<pin>",
254
+ },
255
+ ],
256
+ require: [],
257
+ };
258
+ }
259
+ // A release archive is built for one platform, so it is proposed as
260
+ // remote-install plus a local `require`. Proposing only the install entry
261
+ // produced a manifest that either offered a Linux binary to a laptop or —
262
+ // once narrowed to remote — stopped checking for the tool locally at all.
263
+ // Both halves or neither.
264
+ return {
265
+ install: [
266
+ {
267
+ name: proposal.name,
268
+ version: "<pin>",
269
+ install: "release-tar",
270
+ url: "<release url for this exact version, for the remote platform>",
271
+ sha256: "<sha256 published with that release>",
272
+ surfaces: ["remote"],
273
+ },
274
+ ],
275
+ require: [{ name: proposal.name, surfaces: ["local"] }],
276
+ };
277
+ }
278
+
279
+ if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
280
+ const proposals = detectTooling();
281
+ if (process.argv.includes("--json")) {
282
+ console.log(JSON.stringify(proposals, null, 2));
283
+ } else if (proposals.length === 0) {
284
+ console.log(
285
+ "Every tool this project appears to use is already declared in remoteEnv.tools."
286
+ );
287
+ } else {
288
+ console.log(
289
+ `${proposals.length} tool(s) look required but are not declared:\n`
290
+ );
291
+ for (const proposal of proposals) {
292
+ console.log(` ${proposal.name}`);
293
+ console.log(` why: ${proposal.why}`);
294
+ for (const evidence of proposal.evidence) {
295
+ console.log(` evidence: ${evidence}`);
296
+ }
297
+ const entries = proposedEntries(proposal);
298
+ for (const entry of entries.install) {
299
+ console.log(` tools.install: ${JSON.stringify(entry)}`);
300
+ }
301
+ for (const entry of entries.require) {
302
+ console.log(` tools.require: ${JSON.stringify(entry)}`);
303
+ }
304
+ console.log("");
305
+ }
306
+ console.log(
307
+ "Nothing has been written or installed. Confirm each entry, supply the\n" +
308
+ "pin and checksum, and add it to remoteEnv.tools in .lisa.config.json."
309
+ );
310
+ }
311
+ }
@@ -87,6 +87,14 @@ bash scripts/lisa-remote-env/session-start.sh # guard; delegates to --phase=se
87
87
 
88
88
  The selection comes from the surface's `materializeAt` capability in `lisa-secrets-access`, not from its name, so adding a surface does not mean editing a branch. The hook is committed to the repository, so it also fires on a developer's machine — it exits `0` immediately there rather than failing, because a correct local session must not look broken.
89
89
 
90
+ ## Detect before you provision
91
+
92
+ Run `/lisa:detect-tooling` first, every time. The manifest is the only thing that puts a binary on `PATH`, and nothing populates it — so a project ships npm scripts invoking `maestro`, wires an MCP server whose CLI it also shells out to, and configures Playwright thresholds, while `remoteEnv.tools` stays empty and each of those fails at the moment of use instead of at setup.
93
+
94
+ This repository has paid for that twice: `gh` was declared nowhere and a cloud session could not commit anything; `tar` was needed by an install method and asserted by nothing.
95
+
96
+ The detector proposes and a human decides. It writes nothing, so provisioning still only ever happens from a reviewed, pinned, checksummed entry.
97
+
90
98
  ## Toolchain manifest — two entry kinds
91
99
 
92
100
  ```json
@@ -165,9 +173,9 @@ Because `release-tar` unpacks with `tar` rather than `unzip`, a project using it
165
173
  - **Prefer what is there.** A second Node beside the image's creates PATH ambiguity and wastes container time.
166
174
  - **Pin and checksum together, in one reviewed commit.** A version bump that does not move the checksum fails before installation, not after. An archive is verified *before* it is unpacked, so unexpected contents never reach a directory on PATH.
167
175
 
168
- ## Tooling is never derived from secret notes
176
+ ## Tooling is never *authorised* by secret notes
169
177
 
170
- Deliberately rejected:
178
+ Deliberately rejected — notes may not cause an install:
171
179
 
172
180
  - Most tooling has **no credential** — `jq`, ripgrep, browsers, a linter — so notes could only ever cover a subset, and a second mechanism would be needed anyway.
173
181
  - Most credentials **imply no tool**: a webhook is curl'd, a REST key needs no CLI.
@@ -177,6 +185,14 @@ Deliberately rejected:
177
185
 
178
186
  **Notes are an assertion target instead.** At verify time, a tool that declares a credential which is not materialized is an **error**; a credential materialized with no declared consumer is a **warning**. The arrow points the safe way: the repository declares intent, and the provider is checked against it.
179
187
 
188
+ ### Reading a note as *evidence* is a different act
189
+
190
+ `lisa-detect-tooling` does read notes, and this section is why it may. Every objection above is an objection to a note **causing** something: authorising an install, being the sole source, deciding a version. None of that changes.
191
+
192
+ What the detector produces is a proposal with `<pin>`, `<release url>` and `<sha256>` left blank, printed for a human. It cannot install, cannot write config, and its output is inert until someone commits a pinned, checksummed entry that `assertPinned` then enforces. Provider write access therefore buys an attacker one line of text in a proposal a human reads — not arbitrary install access, which is the specific escalation this section exists to block.
193
+
194
+ The other objections stay true and stay unfixed: most tooling has no credential and most credentials imply no tool, so notes are one signal among four and the weakest of them. They are read *after* npm scripts and MCP servers, and a note alone should be the least persuasive reason to add anything.
195
+
180
196
  ## Provisioning tiers
181
197
 
182
198
  Preference order, falling back:
@@ -221,8 +221,9 @@ function installTool(tool, binDir) {
221
221
  * @param {boolean} dryRun Whether to plan only.
222
222
  * @returns {Array<object>} The plan that was applied.
223
223
  */
224
- function applyToolchain(tools, dryRun) {
225
- const plan = planToolchain(tools, probe);
224
+ function applyToolchain(tools, dryRun, options = {}) {
225
+ const { surface = "remote", consent = true } = options;
226
+ const plan = planToolchain(tools, probe, surface);
226
227
  const blocked = plan.filter(
227
228
  p => p.action === "missing" || p.action === "invalid"
228
229
  );
@@ -234,6 +235,34 @@ function applyToolchain(tools, dryRun) {
234
235
  mkdirSync(binDir, { recursive: true, mode: 0o755 });
235
236
  const byName = new Map((tools.install ?? []).map(t => [t.name, t]));
236
237
 
238
+ // Installing is a different act on a laptop than in a container. A container
239
+ // is disposable and provisioning it silently is the whole point; a developer
240
+ // machine belongs to a person, and putting pinned binaries in their
241
+ // ~/.local/bin without being asked is not ours to decide. So the same
242
+ // manifest drives both, and only the consent differs.
243
+ //
244
+ // Reporting still happens either way — knowing the machine diverges from what
245
+ // the project declares is most of the value, and is what nothing did before.
246
+ if (!consent) {
247
+ const missing = plan.filter(step => step.action === "install");
248
+ if (missing.length === 0) {
249
+ console.log(" every declared tool is already present");
250
+ return plan;
251
+ }
252
+ console.log(
253
+ ` ${missing.length} declared tool(s) are missing or below their pin:`
254
+ );
255
+ for (const step of missing) {
256
+ const tool = byName.get(step.name);
257
+ console.log(` ${step.name}${tool?.version ? ` ${tool.version}` : ""}`);
258
+ }
259
+ console.log(
260
+ "\n Not installing without confirmation. Re-run with --install-tools\n" +
261
+ " to provision them, or install them yourself."
262
+ );
263
+ return plan;
264
+ }
265
+
237
266
  // Installing a binary somewhere nothing looks is the same as not installing
238
267
  // it. `~/.local/bin` is on PATH by default on a developer workstation and is
239
268
  // NOT on a minimal container, so the toolchain step reported `install bws`,
@@ -526,6 +555,40 @@ export function emitClaudeWeb({ bootstrapKey }) {
526
555
  ].join("\n");
527
556
  }
528
557
 
558
+ /**
559
+ * Report tooling the project appears to need but has not declared.
560
+ *
561
+ * Called before the toolchain is applied, because the manifest is the only
562
+ * thing that puts a binary on PATH and nothing populates it — so provisioning
563
+ * "the declared toolchain" happily succeeds while the tool a project actually
564
+ * uses is absent, and fails later at the moment of use instead of here.
565
+ *
566
+ * Informational, never blocking. The detector proposes and a human decides, so
567
+ * an undeclared tool must not stop a setup that was asked to provision what IS
568
+ * declared; failing here would make a advisory signal into a gate nobody chose.
569
+ * Its own absence is silent for the same reason: an older installed copy of the
570
+ * skills has no detector, and that is not a reason to refuse to provision.
571
+ * @param {Function} [exec] Seam for tests.
572
+ */
573
+ function reportUndeclaredTooling(exec = execFileSync) {
574
+ let script;
575
+ try {
576
+ script = siblingScript("lisa-detect-tooling", "detect-tooling.mjs");
577
+ } catch {
578
+ return;
579
+ }
580
+ try {
581
+ const out = exec("node", [script], {
582
+ encoding: "utf8",
583
+ stdio: ["ignore", "pipe", "pipe"],
584
+ });
585
+ const text = String(out).trim();
586
+ if (text) console.log(`\n${text}\n`);
587
+ } catch {
588
+ // A detector that cannot run is a missing hint, not a failed setup.
589
+ }
590
+ }
591
+
529
592
  /**
530
593
  * Decide which phases this invocation runs.
531
594
  *
@@ -640,8 +703,16 @@ async function main() {
640
703
  }
641
704
 
642
705
  if (phases.includes("toolchain")) {
706
+ reportUndeclaredTooling();
643
707
  console.log("Toolchain:");
644
- applyToolchain(cfg.tools, dryRun);
708
+ // A remote container consents by construction: it is disposable, nobody is
709
+ // watching, and provisioning it is why the script exists. Locally the
710
+ // operator opts in per run.
711
+ const remote = Boolean(materializeAt);
712
+ applyToolchain(cfg.tools, dryRun, {
713
+ surface: remote ? "remote" : "local",
714
+ consent: remote || process.argv.includes("--install-tools"),
715
+ });
645
716
  }
646
717
 
647
718
  if (phases.includes("secrets")) {