@codyswann/lisa 3.31.0 → 3.31.2
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/dist/cli/doctor-serialize-legs-contract.d.ts +28 -0
- package/dist/cli/doctor-serialize-legs-contract.d.ts.map +1 -1
- package/dist/cli/doctor-serialize-legs-contract.js +47 -1
- package/dist/cli/doctor-serialize-legs-contract.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +4 -2
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +17 -2
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/surfaces.mjs +88 -5
- package/plugins/lisa/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +17 -2
- package/plugins/lisa/skills/lisa-secrets-access/scripts/surfaces.mjs +88 -5
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +17 -2
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/surfaces.mjs +88 -5
- 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 +1 -1
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +17 -2
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/surfaces.mjs +88 -5
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +17 -2
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/surfaces.mjs +88 -5
- 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/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +17 -2
- package/plugins/src/base/skills/lisa-secrets-access/scripts/surfaces.mjs +88 -5
package/package.json
CHANGED
|
@@ -131,7 +131,7 @@
|
|
|
131
131
|
"ws": ">=8.21.0"
|
|
132
132
|
},
|
|
133
133
|
"name": "@codyswann/lisa",
|
|
134
|
-
"version": "3.31.
|
|
134
|
+
"version": "3.31.2",
|
|
135
135
|
"description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
|
|
136
136
|
"main": "dist/index.js",
|
|
137
137
|
"exports": {
|
|
@@ -189,11 +189,26 @@ export function report(result, cfg) {
|
|
|
189
189
|
} else {
|
|
190
190
|
lines.push(
|
|
191
191
|
`Secrets preflight FAILED on surface "${cfg.surface}".`,
|
|
192
|
-
`These credentials are required and
|
|
193
|
-
`environment,
|
|
192
|
+
`These credentials are required and were not found in the surfaces`,
|
|
193
|
+
`consulted — the environment, the materialized file, and the`,
|
|
194
|
+
`"${cfg.provider}" grant.`,
|
|
194
195
|
`Extend the grant in the vault (or correct the routing that requires`,
|
|
195
196
|
`them), then start a new session:`
|
|
196
197
|
);
|
|
198
|
+
// A defaulted provider is the one case where the list above is not the
|
|
199
|
+
// whole story: no config was found, so the vault a project actually
|
|
200
|
+
// declares was never contacted. Saying "resolve nowhere" there sends the
|
|
201
|
+
// reader to grant a credential that was never absent.
|
|
202
|
+
if (!cfg.configPath) {
|
|
203
|
+
lines.push(
|
|
204
|
+
``,
|
|
205
|
+
`NOTE: no .lisa.config.json was found at or above the working`,
|
|
206
|
+
`directory, so provider "${cfg.provider}" is a DEFAULT, not this`,
|
|
207
|
+
`project's setting. If this project uses a vault, the credential may`,
|
|
208
|
+
`resolve fine — start the session from the repository root, or check`,
|
|
209
|
+
`the config is present, before treating this as missing.`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
197
212
|
}
|
|
198
213
|
lines.push(``);
|
|
199
214
|
for (const { name, reasons } of result.missing) {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { existsSync, readFileSync } from "node:fs";
|
|
17
17
|
import { homedir } from "node:os";
|
|
18
|
-
import { join } from "node:path";
|
|
18
|
+
import { dirname, join, resolve } from "node:path";
|
|
19
19
|
|
|
20
20
|
import { bootstrapKeyFor } from "./providers.mjs";
|
|
21
21
|
import { routingFloor } from "./routing-floor.mjs";
|
|
@@ -182,6 +182,64 @@ export function materializedPaths(namespace, env = process.env) {
|
|
|
182
182
|
};
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Directories the harness claims are this session's project, best first.
|
|
187
|
+
*
|
|
188
|
+
* `CLAUDE_PROJECT_DIR` is set by Claude Code for hook commands, and Lisa's own
|
|
189
|
+
* hooks already invoke scripts through it. `LISA_PROJECT_DIR` lets a harness
|
|
190
|
+
* without that convention — or an operator debugging one — say the same thing.
|
|
191
|
+
*
|
|
192
|
+
* Blank values are dropped rather than resolved, because an unset variable
|
|
193
|
+
* expands to the empty string in a shell and `resolve("")` is the current
|
|
194
|
+
* directory, which would silently re-check a path already searched.
|
|
195
|
+
*
|
|
196
|
+
* @param {Record<string, string | undefined>} env - Environment to read.
|
|
197
|
+
* @returns {string[]} Candidate project roots, deduplicated, best first.
|
|
198
|
+
*/
|
|
199
|
+
function projectAnchors(env) {
|
|
200
|
+
const raw = [env.LISA_PROJECT_DIR, env.CLAUDE_PROJECT_DIR];
|
|
201
|
+
return [...new Set(raw.map(value => (value ?? "").trim()).filter(Boolean))];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Find the nearest `.lisa.config.json` at or above `cwd`.
|
|
206
|
+
*
|
|
207
|
+
* Looking only in `cwd` made every session started anywhere but the repo root
|
|
208
|
+
* — a worktree subdirectory, a package folder, a parent wrapper directory —
|
|
209
|
+
* fall through to the environment defaults and resolve `provider: "env"`. The
|
|
210
|
+
* preflight then checked the environment, the materialized file, and the "env"
|
|
211
|
+
* grant, found nothing, and reported the credential as resolving NOWHERE,
|
|
212
|
+
* having never contacted the configured vault at all.
|
|
213
|
+
*
|
|
214
|
+
* That is a false negative wearing a definite answer's clothes, and the
|
|
215
|
+
* message it produces tells the reader to route the work to blocked. An
|
|
216
|
+
* obedient agent therefore abandons work over credentials it could have
|
|
217
|
+
* resolved, and nothing downstream ever re-derives that judgement.
|
|
218
|
+
*
|
|
219
|
+
* The walk stops at a repository boundary when it finds one, so a config
|
|
220
|
+
* belonging to an unrelated parent project is never adopted. If no boundary is
|
|
221
|
+
* found it stops at the filesystem root, which is what makes the bare-checkout
|
|
222
|
+
* and wrapper-directory layouts work.
|
|
223
|
+
*
|
|
224
|
+
* @param {string} cwd - Directory to start from.
|
|
225
|
+
* @returns {string | null} Absolute path to the config, or `null` if none.
|
|
226
|
+
*/
|
|
227
|
+
function locateConfig(cwd) {
|
|
228
|
+
let dir = resolve(cwd);
|
|
229
|
+
for (;;) {
|
|
230
|
+
const candidate = join(dir, ".lisa.config.json");
|
|
231
|
+
if (existsSync(candidate)) return candidate;
|
|
232
|
+
// Checked AFTER the config, so a repo root carrying one still matches.
|
|
233
|
+
// Checked as a path rather than a directory because a worktree's `.git` is
|
|
234
|
+
// a file, and treating that as "not a boundary" would walk out of the
|
|
235
|
+
// worktree into whatever encloses it.
|
|
236
|
+
if (existsSync(join(dir, ".git"))) return null;
|
|
237
|
+
const parent = dirname(dir);
|
|
238
|
+
if (parent === dir) return null;
|
|
239
|
+
dir = parent;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
185
243
|
/**
|
|
186
244
|
* Read the `secrets` block from `.lisa.config.json`.
|
|
187
245
|
*
|
|
@@ -189,10 +247,28 @@ export function materializedPaths(namespace, env = process.env) {
|
|
|
189
247
|
* provider means the environment *is* the provider. A credentials manager is
|
|
190
248
|
* the preferred path, never a required one.
|
|
191
249
|
* @param {string} [cwd] Directory to look in.
|
|
192
|
-
* @returns {{provider: string, bootstrap: {sources: string[], key: string|null}, require: string[]|null, requiredFloor: readonly string[], rotating: string[], namespace: string, narrow: object, surface: string, routing: {tracker?: string, source?: string}, capabilities: object}} Resolved configuration with defaults applied.
|
|
250
|
+
* @returns {{provider: string, bootstrap: {sources: string[], key: string|null}, require: string[]|null, requiredFloor: readonly string[], rotating: string[], namespace: string, narrow: object, surface: string, routing: {tracker?: string, source?: string}, capabilities: object, configPath: string|null}} Resolved configuration with defaults applied. `configPath` is the file the values came from, or `null` when none was found and the defaults apply.
|
|
193
251
|
*/
|
|
194
252
|
export function readConfig(cwd = process.cwd(), env = process.env) {
|
|
195
|
-
|
|
253
|
+
// Walk from `cwd` first, then from whatever the harness says the project is.
|
|
254
|
+
//
|
|
255
|
+
// A walk from `cwd` alone does not close this. The SessionStart hook that
|
|
256
|
+
// runs the preflight never changes directory, so its cwd is wherever the
|
|
257
|
+
// harness happened to spawn it — `$HOME`, a plugin cache — and a walk from
|
|
258
|
+
// there terminates without finding anything, exactly as a walk from `cwd`
|
|
259
|
+
// inside the project succeeds. Measured on a session whose credential
|
|
260
|
+
// resolved fine from its own worktree while the preflight called it
|
|
261
|
+
// unreachable: every directory from that worktree upward carried a config,
|
|
262
|
+
// so the hook cannot have been running anywhere in that tree.
|
|
263
|
+
//
|
|
264
|
+
// cwd wins when it finds something, because an explicit working directory is
|
|
265
|
+
// intent and the project variable is only ever a hint about the session.
|
|
266
|
+
const path =
|
|
267
|
+
locateConfig(cwd) ??
|
|
268
|
+
projectAnchors(env)
|
|
269
|
+
.map(anchor => locateConfig(anchor))
|
|
270
|
+
.find(found => found !== null) ??
|
|
271
|
+
null;
|
|
196
272
|
// No repository is a real, supported state — not just a missing file.
|
|
197
273
|
//
|
|
198
274
|
// A Claude Tag channel session runs as the organization with no user account,
|
|
@@ -203,7 +279,13 @@ export function readConfig(cwd = process.cwd(), env = process.env) {
|
|
|
203
279
|
// The only values a config would supply here are the namespace and provider,
|
|
204
280
|
// so they can come from the environment instead. Everything else keeps its
|
|
205
281
|
// default, and an environment that sets neither still gets the old behaviour.
|
|
206
|
-
|
|
282
|
+
//
|
|
283
|
+
// It stays supported, but it no longer stays SILENT: `configPath: null` lets
|
|
284
|
+
// a caller say "no config found, assuming provider=env" instead of printing
|
|
285
|
+
// a defaulted provider that is indistinguishable from a declared one.
|
|
286
|
+
if (!path) {
|
|
287
|
+
return withSurface({ ...fromEnvironment(env), configPath: null });
|
|
288
|
+
}
|
|
207
289
|
let root;
|
|
208
290
|
try {
|
|
209
291
|
root = JSON.parse(readFileSync(path, "utf8"));
|
|
@@ -216,7 +298,7 @@ export function readConfig(cwd = process.cwd(), env = process.env) {
|
|
|
216
298
|
// `secrets` would be a second copy free to disagree with the one every other
|
|
217
299
|
// skill dispatches on.
|
|
218
300
|
const routing = { tracker: root.tracker, source: root.source };
|
|
219
|
-
if (!cfg) return withSurface({ ...DEFAULTS, routing });
|
|
301
|
+
if (!cfg) return withSurface({ ...DEFAULTS, routing, configPath: path });
|
|
220
302
|
const provider = cfg.provider ?? DEFAULTS.provider;
|
|
221
303
|
const namespace = assertNamespace(cfg.namespace ?? DEFAULTS.namespace);
|
|
222
304
|
return withSurface({
|
|
@@ -229,6 +311,7 @@ export function readConfig(cwd = process.cwd(), env = process.env) {
|
|
|
229
311
|
narrow: { ...DEFAULTS.narrow, ...(cfg.narrow ?? {}) },
|
|
230
312
|
surface: cfg.surface ?? null,
|
|
231
313
|
routing,
|
|
314
|
+
configPath: path,
|
|
232
315
|
});
|
|
233
316
|
}
|
|
234
317
|
|
|
@@ -189,11 +189,26 @@ export function report(result, cfg) {
|
|
|
189
189
|
} else {
|
|
190
190
|
lines.push(
|
|
191
191
|
`Secrets preflight FAILED on surface "${cfg.surface}".`,
|
|
192
|
-
`These credentials are required and
|
|
193
|
-
`environment,
|
|
192
|
+
`These credentials are required and were not found in the surfaces`,
|
|
193
|
+
`consulted — the environment, the materialized file, and the`,
|
|
194
|
+
`"${cfg.provider}" grant.`,
|
|
194
195
|
`Extend the grant in the vault (or correct the routing that requires`,
|
|
195
196
|
`them), then start a new session:`
|
|
196
197
|
);
|
|
198
|
+
// A defaulted provider is the one case where the list above is not the
|
|
199
|
+
// whole story: no config was found, so the vault a project actually
|
|
200
|
+
// declares was never contacted. Saying "resolve nowhere" there sends the
|
|
201
|
+
// reader to grant a credential that was never absent.
|
|
202
|
+
if (!cfg.configPath) {
|
|
203
|
+
lines.push(
|
|
204
|
+
``,
|
|
205
|
+
`NOTE: no .lisa.config.json was found at or above the working`,
|
|
206
|
+
`directory, so provider "${cfg.provider}" is a DEFAULT, not this`,
|
|
207
|
+
`project's setting. If this project uses a vault, the credential may`,
|
|
208
|
+
`resolve fine — start the session from the repository root, or check`,
|
|
209
|
+
`the config is present, before treating this as missing.`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
197
212
|
}
|
|
198
213
|
lines.push(``);
|
|
199
214
|
for (const { name, reasons } of result.missing) {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { existsSync, readFileSync } from "node:fs";
|
|
17
17
|
import { homedir } from "node:os";
|
|
18
|
-
import { join } from "node:path";
|
|
18
|
+
import { dirname, join, resolve } from "node:path";
|
|
19
19
|
|
|
20
20
|
import { bootstrapKeyFor } from "./providers.mjs";
|
|
21
21
|
import { routingFloor } from "./routing-floor.mjs";
|
|
@@ -182,6 +182,64 @@ export function materializedPaths(namespace, env = process.env) {
|
|
|
182
182
|
};
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Directories the harness claims are this session's project, best first.
|
|
187
|
+
*
|
|
188
|
+
* `CLAUDE_PROJECT_DIR` is set by Claude Code for hook commands, and Lisa's own
|
|
189
|
+
* hooks already invoke scripts through it. `LISA_PROJECT_DIR` lets a harness
|
|
190
|
+
* without that convention — or an operator debugging one — say the same thing.
|
|
191
|
+
*
|
|
192
|
+
* Blank values are dropped rather than resolved, because an unset variable
|
|
193
|
+
* expands to the empty string in a shell and `resolve("")` is the current
|
|
194
|
+
* directory, which would silently re-check a path already searched.
|
|
195
|
+
*
|
|
196
|
+
* @param {Record<string, string | undefined>} env - Environment to read.
|
|
197
|
+
* @returns {string[]} Candidate project roots, deduplicated, best first.
|
|
198
|
+
*/
|
|
199
|
+
function projectAnchors(env) {
|
|
200
|
+
const raw = [env.LISA_PROJECT_DIR, env.CLAUDE_PROJECT_DIR];
|
|
201
|
+
return [...new Set(raw.map(value => (value ?? "").trim()).filter(Boolean))];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Find the nearest `.lisa.config.json` at or above `cwd`.
|
|
206
|
+
*
|
|
207
|
+
* Looking only in `cwd` made every session started anywhere but the repo root
|
|
208
|
+
* — a worktree subdirectory, a package folder, a parent wrapper directory —
|
|
209
|
+
* fall through to the environment defaults and resolve `provider: "env"`. The
|
|
210
|
+
* preflight then checked the environment, the materialized file, and the "env"
|
|
211
|
+
* grant, found nothing, and reported the credential as resolving NOWHERE,
|
|
212
|
+
* having never contacted the configured vault at all.
|
|
213
|
+
*
|
|
214
|
+
* That is a false negative wearing a definite answer's clothes, and the
|
|
215
|
+
* message it produces tells the reader to route the work to blocked. An
|
|
216
|
+
* obedient agent therefore abandons work over credentials it could have
|
|
217
|
+
* resolved, and nothing downstream ever re-derives that judgement.
|
|
218
|
+
*
|
|
219
|
+
* The walk stops at a repository boundary when it finds one, so a config
|
|
220
|
+
* belonging to an unrelated parent project is never adopted. If no boundary is
|
|
221
|
+
* found it stops at the filesystem root, which is what makes the bare-checkout
|
|
222
|
+
* and wrapper-directory layouts work.
|
|
223
|
+
*
|
|
224
|
+
* @param {string} cwd - Directory to start from.
|
|
225
|
+
* @returns {string | null} Absolute path to the config, or `null` if none.
|
|
226
|
+
*/
|
|
227
|
+
function locateConfig(cwd) {
|
|
228
|
+
let dir = resolve(cwd);
|
|
229
|
+
for (;;) {
|
|
230
|
+
const candidate = join(dir, ".lisa.config.json");
|
|
231
|
+
if (existsSync(candidate)) return candidate;
|
|
232
|
+
// Checked AFTER the config, so a repo root carrying one still matches.
|
|
233
|
+
// Checked as a path rather than a directory because a worktree's `.git` is
|
|
234
|
+
// a file, and treating that as "not a boundary" would walk out of the
|
|
235
|
+
// worktree into whatever encloses it.
|
|
236
|
+
if (existsSync(join(dir, ".git"))) return null;
|
|
237
|
+
const parent = dirname(dir);
|
|
238
|
+
if (parent === dir) return null;
|
|
239
|
+
dir = parent;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
185
243
|
/**
|
|
186
244
|
* Read the `secrets` block from `.lisa.config.json`.
|
|
187
245
|
*
|
|
@@ -189,10 +247,28 @@ export function materializedPaths(namespace, env = process.env) {
|
|
|
189
247
|
* provider means the environment *is* the provider. A credentials manager is
|
|
190
248
|
* the preferred path, never a required one.
|
|
191
249
|
* @param {string} [cwd] Directory to look in.
|
|
192
|
-
* @returns {{provider: string, bootstrap: {sources: string[], key: string|null}, require: string[]|null, requiredFloor: readonly string[], rotating: string[], namespace: string, narrow: object, surface: string, routing: {tracker?: string, source?: string}, capabilities: object}} Resolved configuration with defaults applied.
|
|
250
|
+
* @returns {{provider: string, bootstrap: {sources: string[], key: string|null}, require: string[]|null, requiredFloor: readonly string[], rotating: string[], namespace: string, narrow: object, surface: string, routing: {tracker?: string, source?: string}, capabilities: object, configPath: string|null}} Resolved configuration with defaults applied. `configPath` is the file the values came from, or `null` when none was found and the defaults apply.
|
|
193
251
|
*/
|
|
194
252
|
export function readConfig(cwd = process.cwd(), env = process.env) {
|
|
195
|
-
|
|
253
|
+
// Walk from `cwd` first, then from whatever the harness says the project is.
|
|
254
|
+
//
|
|
255
|
+
// A walk from `cwd` alone does not close this. The SessionStart hook that
|
|
256
|
+
// runs the preflight never changes directory, so its cwd is wherever the
|
|
257
|
+
// harness happened to spawn it — `$HOME`, a plugin cache — and a walk from
|
|
258
|
+
// there terminates without finding anything, exactly as a walk from `cwd`
|
|
259
|
+
// inside the project succeeds. Measured on a session whose credential
|
|
260
|
+
// resolved fine from its own worktree while the preflight called it
|
|
261
|
+
// unreachable: every directory from that worktree upward carried a config,
|
|
262
|
+
// so the hook cannot have been running anywhere in that tree.
|
|
263
|
+
//
|
|
264
|
+
// cwd wins when it finds something, because an explicit working directory is
|
|
265
|
+
// intent and the project variable is only ever a hint about the session.
|
|
266
|
+
const path =
|
|
267
|
+
locateConfig(cwd) ??
|
|
268
|
+
projectAnchors(env)
|
|
269
|
+
.map(anchor => locateConfig(anchor))
|
|
270
|
+
.find(found => found !== null) ??
|
|
271
|
+
null;
|
|
196
272
|
// No repository is a real, supported state — not just a missing file.
|
|
197
273
|
//
|
|
198
274
|
// A Claude Tag channel session runs as the organization with no user account,
|
|
@@ -203,7 +279,13 @@ export function readConfig(cwd = process.cwd(), env = process.env) {
|
|
|
203
279
|
// The only values a config would supply here are the namespace and provider,
|
|
204
280
|
// so they can come from the environment instead. Everything else keeps its
|
|
205
281
|
// default, and an environment that sets neither still gets the old behaviour.
|
|
206
|
-
|
|
282
|
+
//
|
|
283
|
+
// It stays supported, but it no longer stays SILENT: `configPath: null` lets
|
|
284
|
+
// a caller say "no config found, assuming provider=env" instead of printing
|
|
285
|
+
// a defaulted provider that is indistinguishable from a declared one.
|
|
286
|
+
if (!path) {
|
|
287
|
+
return withSurface({ ...fromEnvironment(env), configPath: null });
|
|
288
|
+
}
|
|
207
289
|
let root;
|
|
208
290
|
try {
|
|
209
291
|
root = JSON.parse(readFileSync(path, "utf8"));
|
|
@@ -216,7 +298,7 @@ export function readConfig(cwd = process.cwd(), env = process.env) {
|
|
|
216
298
|
// `secrets` would be a second copy free to disagree with the one every other
|
|
217
299
|
// skill dispatches on.
|
|
218
300
|
const routing = { tracker: root.tracker, source: root.source };
|
|
219
|
-
if (!cfg) return withSurface({ ...DEFAULTS, routing });
|
|
301
|
+
if (!cfg) return withSurface({ ...DEFAULTS, routing, configPath: path });
|
|
220
302
|
const provider = cfg.provider ?? DEFAULTS.provider;
|
|
221
303
|
const namespace = assertNamespace(cfg.namespace ?? DEFAULTS.namespace);
|
|
222
304
|
return withSurface({
|
|
@@ -229,6 +311,7 @@ export function readConfig(cwd = process.cwd(), env = process.env) {
|
|
|
229
311
|
narrow: { ...DEFAULTS.narrow, ...(cfg.narrow ?? {}) },
|
|
230
312
|
surface: cfg.surface ?? null,
|
|
231
313
|
routing,
|
|
314
|
+
configPath: path,
|
|
232
315
|
});
|
|
233
316
|
}
|
|
234
317
|
|
|
@@ -189,11 +189,26 @@ export function report(result, cfg) {
|
|
|
189
189
|
} else {
|
|
190
190
|
lines.push(
|
|
191
191
|
`Secrets preflight FAILED on surface "${cfg.surface}".`,
|
|
192
|
-
`These credentials are required and
|
|
193
|
-
`environment,
|
|
192
|
+
`These credentials are required and were not found in the surfaces`,
|
|
193
|
+
`consulted — the environment, the materialized file, and the`,
|
|
194
|
+
`"${cfg.provider}" grant.`,
|
|
194
195
|
`Extend the grant in the vault (or correct the routing that requires`,
|
|
195
196
|
`them), then start a new session:`
|
|
196
197
|
);
|
|
198
|
+
// A defaulted provider is the one case where the list above is not the
|
|
199
|
+
// whole story: no config was found, so the vault a project actually
|
|
200
|
+
// declares was never contacted. Saying "resolve nowhere" there sends the
|
|
201
|
+
// reader to grant a credential that was never absent.
|
|
202
|
+
if (!cfg.configPath) {
|
|
203
|
+
lines.push(
|
|
204
|
+
``,
|
|
205
|
+
`NOTE: no .lisa.config.json was found at or above the working`,
|
|
206
|
+
`directory, so provider "${cfg.provider}" is a DEFAULT, not this`,
|
|
207
|
+
`project's setting. If this project uses a vault, the credential may`,
|
|
208
|
+
`resolve fine — start the session from the repository root, or check`,
|
|
209
|
+
`the config is present, before treating this as missing.`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
197
212
|
}
|
|
198
213
|
lines.push(``);
|
|
199
214
|
for (const { name, reasons } of result.missing) {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { existsSync, readFileSync } from "node:fs";
|
|
17
17
|
import { homedir } from "node:os";
|
|
18
|
-
import { join } from "node:path";
|
|
18
|
+
import { dirname, join, resolve } from "node:path";
|
|
19
19
|
|
|
20
20
|
import { bootstrapKeyFor } from "./providers.mjs";
|
|
21
21
|
import { routingFloor } from "./routing-floor.mjs";
|
|
@@ -182,6 +182,64 @@ export function materializedPaths(namespace, env = process.env) {
|
|
|
182
182
|
};
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Directories the harness claims are this session's project, best first.
|
|
187
|
+
*
|
|
188
|
+
* `CLAUDE_PROJECT_DIR` is set by Claude Code for hook commands, and Lisa's own
|
|
189
|
+
* hooks already invoke scripts through it. `LISA_PROJECT_DIR` lets a harness
|
|
190
|
+
* without that convention — or an operator debugging one — say the same thing.
|
|
191
|
+
*
|
|
192
|
+
* Blank values are dropped rather than resolved, because an unset variable
|
|
193
|
+
* expands to the empty string in a shell and `resolve("")` is the current
|
|
194
|
+
* directory, which would silently re-check a path already searched.
|
|
195
|
+
*
|
|
196
|
+
* @param {Record<string, string | undefined>} env - Environment to read.
|
|
197
|
+
* @returns {string[]} Candidate project roots, deduplicated, best first.
|
|
198
|
+
*/
|
|
199
|
+
function projectAnchors(env) {
|
|
200
|
+
const raw = [env.LISA_PROJECT_DIR, env.CLAUDE_PROJECT_DIR];
|
|
201
|
+
return [...new Set(raw.map(value => (value ?? "").trim()).filter(Boolean))];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Find the nearest `.lisa.config.json` at or above `cwd`.
|
|
206
|
+
*
|
|
207
|
+
* Looking only in `cwd` made every session started anywhere but the repo root
|
|
208
|
+
* — a worktree subdirectory, a package folder, a parent wrapper directory —
|
|
209
|
+
* fall through to the environment defaults and resolve `provider: "env"`. The
|
|
210
|
+
* preflight then checked the environment, the materialized file, and the "env"
|
|
211
|
+
* grant, found nothing, and reported the credential as resolving NOWHERE,
|
|
212
|
+
* having never contacted the configured vault at all.
|
|
213
|
+
*
|
|
214
|
+
* That is a false negative wearing a definite answer's clothes, and the
|
|
215
|
+
* message it produces tells the reader to route the work to blocked. An
|
|
216
|
+
* obedient agent therefore abandons work over credentials it could have
|
|
217
|
+
* resolved, and nothing downstream ever re-derives that judgement.
|
|
218
|
+
*
|
|
219
|
+
* The walk stops at a repository boundary when it finds one, so a config
|
|
220
|
+
* belonging to an unrelated parent project is never adopted. If no boundary is
|
|
221
|
+
* found it stops at the filesystem root, which is what makes the bare-checkout
|
|
222
|
+
* and wrapper-directory layouts work.
|
|
223
|
+
*
|
|
224
|
+
* @param {string} cwd - Directory to start from.
|
|
225
|
+
* @returns {string | null} Absolute path to the config, or `null` if none.
|
|
226
|
+
*/
|
|
227
|
+
function locateConfig(cwd) {
|
|
228
|
+
let dir = resolve(cwd);
|
|
229
|
+
for (;;) {
|
|
230
|
+
const candidate = join(dir, ".lisa.config.json");
|
|
231
|
+
if (existsSync(candidate)) return candidate;
|
|
232
|
+
// Checked AFTER the config, so a repo root carrying one still matches.
|
|
233
|
+
// Checked as a path rather than a directory because a worktree's `.git` is
|
|
234
|
+
// a file, and treating that as "not a boundary" would walk out of the
|
|
235
|
+
// worktree into whatever encloses it.
|
|
236
|
+
if (existsSync(join(dir, ".git"))) return null;
|
|
237
|
+
const parent = dirname(dir);
|
|
238
|
+
if (parent === dir) return null;
|
|
239
|
+
dir = parent;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
185
243
|
/**
|
|
186
244
|
* Read the `secrets` block from `.lisa.config.json`.
|
|
187
245
|
*
|
|
@@ -189,10 +247,28 @@ export function materializedPaths(namespace, env = process.env) {
|
|
|
189
247
|
* provider means the environment *is* the provider. A credentials manager is
|
|
190
248
|
* the preferred path, never a required one.
|
|
191
249
|
* @param {string} [cwd] Directory to look in.
|
|
192
|
-
* @returns {{provider: string, bootstrap: {sources: string[], key: string|null}, require: string[]|null, requiredFloor: readonly string[], rotating: string[], namespace: string, narrow: object, surface: string, routing: {tracker?: string, source?: string}, capabilities: object}} Resolved configuration with defaults applied.
|
|
250
|
+
* @returns {{provider: string, bootstrap: {sources: string[], key: string|null}, require: string[]|null, requiredFloor: readonly string[], rotating: string[], namespace: string, narrow: object, surface: string, routing: {tracker?: string, source?: string}, capabilities: object, configPath: string|null}} Resolved configuration with defaults applied. `configPath` is the file the values came from, or `null` when none was found and the defaults apply.
|
|
193
251
|
*/
|
|
194
252
|
export function readConfig(cwd = process.cwd(), env = process.env) {
|
|
195
|
-
|
|
253
|
+
// Walk from `cwd` first, then from whatever the harness says the project is.
|
|
254
|
+
//
|
|
255
|
+
// A walk from `cwd` alone does not close this. The SessionStart hook that
|
|
256
|
+
// runs the preflight never changes directory, so its cwd is wherever the
|
|
257
|
+
// harness happened to spawn it — `$HOME`, a plugin cache — and a walk from
|
|
258
|
+
// there terminates without finding anything, exactly as a walk from `cwd`
|
|
259
|
+
// inside the project succeeds. Measured on a session whose credential
|
|
260
|
+
// resolved fine from its own worktree while the preflight called it
|
|
261
|
+
// unreachable: every directory from that worktree upward carried a config,
|
|
262
|
+
// so the hook cannot have been running anywhere in that tree.
|
|
263
|
+
//
|
|
264
|
+
// cwd wins when it finds something, because an explicit working directory is
|
|
265
|
+
// intent and the project variable is only ever a hint about the session.
|
|
266
|
+
const path =
|
|
267
|
+
locateConfig(cwd) ??
|
|
268
|
+
projectAnchors(env)
|
|
269
|
+
.map(anchor => locateConfig(anchor))
|
|
270
|
+
.find(found => found !== null) ??
|
|
271
|
+
null;
|
|
196
272
|
// No repository is a real, supported state — not just a missing file.
|
|
197
273
|
//
|
|
198
274
|
// A Claude Tag channel session runs as the organization with no user account,
|
|
@@ -203,7 +279,13 @@ export function readConfig(cwd = process.cwd(), env = process.env) {
|
|
|
203
279
|
// The only values a config would supply here are the namespace and provider,
|
|
204
280
|
// so they can come from the environment instead. Everything else keeps its
|
|
205
281
|
// default, and an environment that sets neither still gets the old behaviour.
|
|
206
|
-
|
|
282
|
+
//
|
|
283
|
+
// It stays supported, but it no longer stays SILENT: `configPath: null` lets
|
|
284
|
+
// a caller say "no config found, assuming provider=env" instead of printing
|
|
285
|
+
// a defaulted provider that is indistinguishable from a declared one.
|
|
286
|
+
if (!path) {
|
|
287
|
+
return withSurface({ ...fromEnvironment(env), configPath: null });
|
|
288
|
+
}
|
|
207
289
|
let root;
|
|
208
290
|
try {
|
|
209
291
|
root = JSON.parse(readFileSync(path, "utf8"));
|
|
@@ -216,7 +298,7 @@ export function readConfig(cwd = process.cwd(), env = process.env) {
|
|
|
216
298
|
// `secrets` would be a second copy free to disagree with the one every other
|
|
217
299
|
// skill dispatches on.
|
|
218
300
|
const routing = { tracker: root.tracker, source: root.source };
|
|
219
|
-
if (!cfg) return withSurface({ ...DEFAULTS, routing });
|
|
301
|
+
if (!cfg) return withSurface({ ...DEFAULTS, routing, configPath: path });
|
|
220
302
|
const provider = cfg.provider ?? DEFAULTS.provider;
|
|
221
303
|
const namespace = assertNamespace(cfg.namespace ?? DEFAULTS.namespace);
|
|
222
304
|
return withSurface({
|
|
@@ -229,6 +311,7 @@ export function readConfig(cwd = process.cwd(), env = process.env) {
|
|
|
229
311
|
narrow: { ...DEFAULTS.narrow, ...(cfg.narrow ?? {}) },
|
|
230
312
|
surface: cfg.surface ?? null,
|
|
231
313
|
routing,
|
|
314
|
+
configPath: path,
|
|
232
315
|
});
|
|
233
316
|
}
|
|
234
317
|
|
|
@@ -189,11 +189,26 @@ export function report(result, cfg) {
|
|
|
189
189
|
} else {
|
|
190
190
|
lines.push(
|
|
191
191
|
`Secrets preflight FAILED on surface "${cfg.surface}".`,
|
|
192
|
-
`These credentials are required and
|
|
193
|
-
`environment,
|
|
192
|
+
`These credentials are required and were not found in the surfaces`,
|
|
193
|
+
`consulted — the environment, the materialized file, and the`,
|
|
194
|
+
`"${cfg.provider}" grant.`,
|
|
194
195
|
`Extend the grant in the vault (or correct the routing that requires`,
|
|
195
196
|
`them), then start a new session:`
|
|
196
197
|
);
|
|
198
|
+
// A defaulted provider is the one case where the list above is not the
|
|
199
|
+
// whole story: no config was found, so the vault a project actually
|
|
200
|
+
// declares was never contacted. Saying "resolve nowhere" there sends the
|
|
201
|
+
// reader to grant a credential that was never absent.
|
|
202
|
+
if (!cfg.configPath) {
|
|
203
|
+
lines.push(
|
|
204
|
+
``,
|
|
205
|
+
`NOTE: no .lisa.config.json was found at or above the working`,
|
|
206
|
+
`directory, so provider "${cfg.provider}" is a DEFAULT, not this`,
|
|
207
|
+
`project's setting. If this project uses a vault, the credential may`,
|
|
208
|
+
`resolve fine — start the session from the repository root, or check`,
|
|
209
|
+
`the config is present, before treating this as missing.`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
197
212
|
}
|
|
198
213
|
lines.push(``);
|
|
199
214
|
for (const { name, reasons } of result.missing) {
|