@codyswann/lisa 4.31.2 → 4.32.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.
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +15 -2
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +4 -4
- package/plugins/lisa/.claude-plugin/plugin.json +19 -1
- package/plugins/lisa/.codex-plugin/hooks.json +18 -0
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/hooks/inject-resolved-config.mjs +669 -0
- package/plugins/lisa/hooks/inject-resolved-config.sh +48 -0
- package/plugins/lisa/rules/eager/config-resolution.md +8 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- 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/inject-resolved-config.mjs +669 -0
- package/plugins/lisa-copilot/hooks/inject-resolved-config.sh +48 -0
- package/plugins/lisa-copilot/rules/eager/config-resolution.md +8 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/hooks.json +6 -0
- package/plugins/lisa-cursor/hooks/inject-resolved-config.mjs +669 -0
- package/plugins/lisa-cursor/hooks/inject-resolved-config.sh +48 -0
- package/plugins/lisa-cursor/rules/config-resolution.mdc +8 -1
- 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 +18 -0
- package/plugins/src/base/hooks/inject-resolved-config.mjs +669 -0
- package/plugins/src/base/hooks/inject-resolved-config.sh +48 -0
- package/plugins/src/base/rules/eager/config-resolution.md +8 -1
- package/scripts/lib/per-agent-hook-filter.mjs +20 -0
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Put the RESOLVED Lisa configuration in front of the agent, so reading it is
|
|
4
|
+
* not a decision anyone can decline to make.
|
|
5
|
+
*
|
|
6
|
+
* ## The defect this exists against
|
|
7
|
+
*
|
|
8
|
+
* Projects keep "missing" `.lisa.config.json`. The file is present and
|
|
9
|
+
* correct; agents act against what it declares anyway. The file is not
|
|
10
|
+
* missing — it is unread.
|
|
11
|
+
*
|
|
12
|
+
* The mechanism is visible in what SessionStart already ships. `inject-rules.sh`
|
|
13
|
+
* injects RULE TEXT through `hookSpecificOutput.additionalContext`. Nothing
|
|
14
|
+
* injected the resolved VALUES. So an agent received a paragraph saying that
|
|
15
|
+
* configuration lives in a file, and then had to decide, unprompted, to go read
|
|
16
|
+
* that file. That decision is the step that gets skipped.
|
|
17
|
+
*
|
|
18
|
+
* ## Why this is not more prose
|
|
19
|
+
*
|
|
20
|
+
* `config-resolution.md` is already an EAGER rule — auto-loaded into every
|
|
21
|
+
* session — and it already opens with the words "load-bearing". Nine eager
|
|
22
|
+
* rules already name `.lisa.config.json`. A tenth assertion added to a stack
|
|
23
|
+
* that is demonstrably being read past is not a fix; this repository's own
|
|
24
|
+
* measurement is that executable controls land at 100% and prose rules at
|
|
25
|
+
* roughly zero. An agent cannot skip reading what is already in its context.
|
|
26
|
+
*
|
|
27
|
+
* ## Three properties, none of them decoration
|
|
28
|
+
*
|
|
29
|
+
* 1. RESOLVED, not declared. `.lisa.config.local.json` overrides
|
|
30
|
+
* `.lisa.config.json` where they overlap, so nobody can compute an effective
|
|
31
|
+
* value by reading one file — which is part of why the read gets skipped.
|
|
32
|
+
* 2. DECLARED vs DEFAULTED is marked. Without it, a silently absent or
|
|
33
|
+
* partial config is indistinguishable from one that happens to agree with
|
|
34
|
+
* Lisa's built-ins, and a gap has no symptom at all.
|
|
35
|
+
* 3. NO CONFIG is stated out loud. Emitting nothing when there is no config is
|
|
36
|
+
* the original failure mode wearing a new hat.
|
|
37
|
+
*
|
|
38
|
+
* ## Bounded, because this lands in every session
|
|
39
|
+
*
|
|
40
|
+
* {@link CONTEXT_BUDGET} caps the rendered body. Collections are summarised
|
|
41
|
+
* rather than dumped, and `gates` — the largest block in a real config, and the
|
|
42
|
+
* one an agent most often violates — gets its own renderer that groups gate ids
|
|
43
|
+
* by moment and level instead of truncating the list. What does not fit is
|
|
44
|
+
* counted, never silently dropped.
|
|
45
|
+
*
|
|
46
|
+
* ## Fail soft, always
|
|
47
|
+
*
|
|
48
|
+
* Every exit is 0. A malformed or unreadable config emits one line saying so
|
|
49
|
+
* and continues; a hook that kills session start is worse than the problem it
|
|
50
|
+
* was added to solve.
|
|
51
|
+
* @module inject-resolved-config
|
|
52
|
+
*/
|
|
53
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
54
|
+
import { join } from "node:path";
|
|
55
|
+
import { fileURLToPath } from "node:url";
|
|
56
|
+
|
|
57
|
+
/** Committed, project-wide configuration. */
|
|
58
|
+
const MAIN_CONFIG = ".lisa.config.json";
|
|
59
|
+
/** Gitignored per-developer overrides. Wins where the two overlap. */
|
|
60
|
+
const LOCAL_CONFIG = ".lisa.config.local.json";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Character budget for the rendered value body.
|
|
64
|
+
*
|
|
65
|
+
* This block is injected into EVERY session and every subagent, so it competes
|
|
66
|
+
* with the work for context. 4000 characters is roughly one page: enough for a
|
|
67
|
+
* mature project's full identity, lifecycle, gate and policy picture (this
|
|
68
|
+
* repository's own config renders to about 3KB), and small enough that it can
|
|
69
|
+
* never crowd out the task. Over budget, rendering stops and the number of
|
|
70
|
+
* unrendered keys is reported — a silent truncation would reintroduce exactly
|
|
71
|
+
* the invisible-gap failure this hook exists to remove.
|
|
72
|
+
*/
|
|
73
|
+
const CONTEXT_BUDGET = 4000;
|
|
74
|
+
|
|
75
|
+
/** Most children of one parent object rendered before summarising the rest. */
|
|
76
|
+
const MAX_CHILDREN = 12;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Longest single rendered line.
|
|
80
|
+
*
|
|
81
|
+
* {@link MAX_CHILDREN} bounds how MANY values a line carries, not how long each
|
|
82
|
+
* one is, so a config holding a dozen long strings under one parent could
|
|
83
|
+
* otherwise emit a single line that consumed most of {@link CONTEXT_BUDGET} on
|
|
84
|
+
* its own — and the budget check, which reads a line as indivisible, would let
|
|
85
|
+
* it through whole.
|
|
86
|
+
*/
|
|
87
|
+
const MAX_LINE = 400;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The gate-level vocabulary, copied from `LEVELS` in
|
|
91
|
+
* `all/copy-overwrite/scripts/lisa-gates.mjs` (a shipped script this hook
|
|
92
|
+
* cannot import from inside a host project's plugin payload), and pinned
|
|
93
|
+
* against the real export by this hook's unit test.
|
|
94
|
+
*
|
|
95
|
+
* It is how a MOMENT is recognised without copying the moment vocabulary too:
|
|
96
|
+
* a gate's `pull-request` key resolves to one of these and a gate's `run` key
|
|
97
|
+
* resolves to a task name, so the levels alone separate the two. Copying the
|
|
98
|
+
* smaller list also means a new moment family reaches this renderer for free.
|
|
99
|
+
*/
|
|
100
|
+
const LEVELS = new Set(["required", "optional", "off"]);
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Words that make a key identity- or credential-shaped.
|
|
104
|
+
*
|
|
105
|
+
* Matched against each path segment after splitting on `_`, `-`, `.` and
|
|
106
|
+
* camelCase boundaries, so `atlassian.email`, `apiKey`, `bypass_actors` and
|
|
107
|
+
* `githubToken` are all caught by a single-word entry.
|
|
108
|
+
*
|
|
109
|
+
* Deliberately broad. The instruction for an unclear key is to OMIT it: a
|
|
110
|
+
* config value withheld from context costs one file read, and an identity or
|
|
111
|
+
* credential injected into context cannot be withdrawn. Adding a word here is
|
|
112
|
+
* always safe; removing one is a security change.
|
|
113
|
+
*/
|
|
114
|
+
const SENSITIVE_WORDS = new Set([
|
|
115
|
+
"account",
|
|
116
|
+
"actor",
|
|
117
|
+
"actors",
|
|
118
|
+
"apikey",
|
|
119
|
+
"auth",
|
|
120
|
+
"bearer",
|
|
121
|
+
"cookie",
|
|
122
|
+
"credential",
|
|
123
|
+
"credentials",
|
|
124
|
+
"dsn",
|
|
125
|
+
"email",
|
|
126
|
+
"identity",
|
|
127
|
+
"key",
|
|
128
|
+
"keys",
|
|
129
|
+
"login",
|
|
130
|
+
"mail",
|
|
131
|
+
"oauth",
|
|
132
|
+
"passphrase",
|
|
133
|
+
"passwd",
|
|
134
|
+
"password",
|
|
135
|
+
"pat",
|
|
136
|
+
"secret",
|
|
137
|
+
"session",
|
|
138
|
+
"signature",
|
|
139
|
+
"token",
|
|
140
|
+
"user",
|
|
141
|
+
"username",
|
|
142
|
+
"webhook",
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Second line of defence: a value that LOOKS like a developer identity is
|
|
147
|
+
* redacted wherever it appears, whatever key it arrived under.
|
|
148
|
+
*
|
|
149
|
+
* {@link SENSITIVE_WORDS} guards the keys that exist today. This guards the
|
|
150
|
+
* ones added tomorrow by someone who never read this file.
|
|
151
|
+
*
|
|
152
|
+
* `=` and `,` are excluded from both sides deliberately. `\b[^\s@]+@...`
|
|
153
|
+
* swallows the `key=` prefix a rendered line puts in front of the value, so the
|
|
154
|
+
* redaction erased WHICH key had been redacted — and, worse, made a test
|
|
155
|
+
* asserting the key name was absent pass even with the key-name filter removed.
|
|
156
|
+
*/
|
|
157
|
+
const EMAIL_SHAPED = /[^\s@,=]+@[^\s@,=]+\.[^\s@,=]+/g;
|
|
158
|
+
|
|
159
|
+
/** What replaces a redacted value, so the KEY's existence is still visible. */
|
|
160
|
+
const REDACTED = "[redacted]";
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Keys Lisa supplies a built-in value for when a project declares none.
|
|
164
|
+
*
|
|
165
|
+
* Each entry names the module that OWNS the constant. This table is a copy for
|
|
166
|
+
* one reason — the constants live in a TypeScript module and a shipped
|
|
167
|
+
* `all/` script, neither importable from a plugin hook running inside an
|
|
168
|
+
* arbitrary host project — and `tests/unit/hooks/inject-resolved-config.test.ts`
|
|
169
|
+
* imports the real constants and asserts this table still agrees with them, so
|
|
170
|
+
* the copy cannot drift silently.
|
|
171
|
+
* @type {ReadonlyArray<{path: string, value: string, owner: string}>}
|
|
172
|
+
*/
|
|
173
|
+
const BUILT_IN_DEFAULTS = [
|
|
174
|
+
{ path: "harness", value: "claude", owner: "src/core/config.ts" },
|
|
175
|
+
{
|
|
176
|
+
path: "gates.runner",
|
|
177
|
+
value: "npm run",
|
|
178
|
+
owner: "all/copy-overwrite/scripts/lisa-gates.mjs",
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
path: "gates.unproven",
|
|
182
|
+
value: "warn",
|
|
183
|
+
owner: "all/copy-overwrite/scripts/lisa-gates.mjs",
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
path: "learnings.file",
|
|
187
|
+
value: ".lisa/PROJECT_LEARNINGS.md",
|
|
188
|
+
owner: "src/core/learnings-location.ts",
|
|
189
|
+
},
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Keys with NO built-in fallback, whose absence stops a flow rather than
|
|
194
|
+
* defaulting it. Reported as explicitly undeclared, because "absent" and
|
|
195
|
+
* "happens to match the default" are the two states this hook exists to
|
|
196
|
+
* separate and only one of them has a value to print.
|
|
197
|
+
*/
|
|
198
|
+
const REQUIRED_KEYS = ["tracker", "deploy.branches"];
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Top-level subtrees rendered, in the order an agent needs them.
|
|
202
|
+
*
|
|
203
|
+
* A subtree allowlist rather than a leaf allowlist: a key added to `github` or
|
|
204
|
+
* `policy` upstream reaches the agent without anyone remembering to edit this
|
|
205
|
+
* file. That is also why {@link SENSITIVE_WORDS} is load-bearing rather than
|
|
206
|
+
* belt-and-braces — `atlassian` is on this list, so `atlassian.email` is
|
|
207
|
+
* REACHED here and kept out by the redaction filter alone.
|
|
208
|
+
*/
|
|
209
|
+
const SUBTREE_ORDER = [
|
|
210
|
+
"harness",
|
|
211
|
+
"tracker",
|
|
212
|
+
"source",
|
|
213
|
+
"repo",
|
|
214
|
+
"workItem",
|
|
215
|
+
"deploy",
|
|
216
|
+
"github",
|
|
217
|
+
"jira",
|
|
218
|
+
"linear",
|
|
219
|
+
"notion",
|
|
220
|
+
"confluence",
|
|
221
|
+
"atlassian",
|
|
222
|
+
"gates",
|
|
223
|
+
"policy",
|
|
224
|
+
"quality",
|
|
225
|
+
"learnings",
|
|
226
|
+
"verification",
|
|
227
|
+
"nightlyE2E",
|
|
228
|
+
"intake",
|
|
229
|
+
"monitor",
|
|
230
|
+
"automations",
|
|
231
|
+
"remoteEnv",
|
|
232
|
+
];
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Split a config key into lowercase words for sensitivity matching.
|
|
236
|
+
* @param {string} segment One path segment, e.g. `bypass_actors` or `apiKey`.
|
|
237
|
+
* @returns {string[]} Lowercase words.
|
|
238
|
+
*/
|
|
239
|
+
function words(segment) {
|
|
240
|
+
return segment
|
|
241
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
242
|
+
.split(/[\s._-]+/)
|
|
243
|
+
.filter(Boolean)
|
|
244
|
+
.map(word => word.toLowerCase());
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Whether a dotted config path is identity- or credential-shaped.
|
|
249
|
+
* @param {string} path Dotted path, e.g. `atlassian.email`.
|
|
250
|
+
* @returns {boolean} True when the path must not be rendered.
|
|
251
|
+
*/
|
|
252
|
+
function isSensitivePath(path) {
|
|
253
|
+
return path
|
|
254
|
+
.split(".")
|
|
255
|
+
.some(segment => words(segment).some(word => SENSITIVE_WORDS.has(word)));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Redact anything email-shaped in already-rendered text.
|
|
260
|
+
* @param {string} text Rendered line or block.
|
|
261
|
+
* @returns {string} The text with identity-shaped values replaced.
|
|
262
|
+
*/
|
|
263
|
+
function scrub(text) {
|
|
264
|
+
return text.replace(EMAIL_SHAPED, REDACTED);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Whether a value is a plain object worth recursing into.
|
|
269
|
+
* @param {unknown} value Any config value.
|
|
270
|
+
* @returns {boolean} True for non-array objects.
|
|
271
|
+
*/
|
|
272
|
+
function isObject(value) {
|
|
273
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Deep-merge the local override onto the committed config.
|
|
278
|
+
*
|
|
279
|
+
* Objects merge key by key so a local file naming one leaf does not erase its
|
|
280
|
+
* siblings; every non-object — scalars and arrays alike — is replaced whole.
|
|
281
|
+
* That reproduces the documented per-key resolution (`jq '.a.b' local` falling
|
|
282
|
+
* through to the committed file) for every path, which is the semantics the
|
|
283
|
+
* rest of Lisa reads with.
|
|
284
|
+
* @param {unknown} base Committed config value.
|
|
285
|
+
* @param {unknown} override Local config value.
|
|
286
|
+
* @returns {unknown} The resolved value.
|
|
287
|
+
*/
|
|
288
|
+
function deepMerge(base, override) {
|
|
289
|
+
if (override === undefined) return base;
|
|
290
|
+
if (!isObject(base) || !isObject(override)) return override;
|
|
291
|
+
const merged = { ...base };
|
|
292
|
+
for (const [key, value] of Object.entries(override)) {
|
|
293
|
+
merged[key] = deepMerge(base[key], value);
|
|
294
|
+
}
|
|
295
|
+
return merged;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Read one JSON file.
|
|
300
|
+
* @param {string} path Absolute path.
|
|
301
|
+
* @returns {{state: "absent"} | {state: "ok", value: unknown} | {state: "bad", reason: string}}
|
|
302
|
+
* What was found. `bad` covers unreadable and unparseable alike — from the
|
|
303
|
+
* agent's seat those are the same fact.
|
|
304
|
+
*/
|
|
305
|
+
function readJson(path) {
|
|
306
|
+
if (!existsSync(path)) return { state: "absent" };
|
|
307
|
+
try {
|
|
308
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
309
|
+
if (!isObject(parsed)) {
|
|
310
|
+
return { state: "bad", reason: "root is not a JSON object" };
|
|
311
|
+
}
|
|
312
|
+
return { state: "ok", value: parsed };
|
|
313
|
+
} catch (err) {
|
|
314
|
+
return { state: "bad", reason: err.message };
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Look a dotted path up in the resolved config.
|
|
320
|
+
* @param {object} config Resolved config.
|
|
321
|
+
* @param {string} path Dotted path.
|
|
322
|
+
* @returns {unknown} The value, or undefined when the path is not declared.
|
|
323
|
+
*/
|
|
324
|
+
function at(config, path) {
|
|
325
|
+
return path
|
|
326
|
+
.split(".")
|
|
327
|
+
.reduce(
|
|
328
|
+
(node, key) => (isObject(node) ? node[key] : undefined),
|
|
329
|
+
/** @type {unknown} */ (config)
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Render one leaf value compactly.
|
|
335
|
+
* @param {unknown} value A non-object config value.
|
|
336
|
+
* @returns {string} A one-line rendering.
|
|
337
|
+
*/
|
|
338
|
+
function renderLeaf(value) {
|
|
339
|
+
if (Array.isArray(value)) {
|
|
340
|
+
const scalars = value.filter(
|
|
341
|
+
item => !isObject(item) && !Array.isArray(item)
|
|
342
|
+
);
|
|
343
|
+
return scalars.length === value.length && value.length <= 4
|
|
344
|
+
? `[${scalars.join(", ")}]`
|
|
345
|
+
: `[${value.length} ${value.length === 1 ? "entry" : "entries"}]`;
|
|
346
|
+
}
|
|
347
|
+
return String(value);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Render a subtree as `path: key=value, key=value` lines.
|
|
352
|
+
*
|
|
353
|
+
* One line per leaf-bearing object rather than one line per leaf: a real config
|
|
354
|
+
* has well over a hundred leaves, and a hundred-line block is a dump, not a
|
|
355
|
+
* briefing.
|
|
356
|
+
* @param {unknown} node The subtree.
|
|
357
|
+
* @param {string} path Dotted path of this node.
|
|
358
|
+
* @param {string[]} out Lines accumulated so far (mutated).
|
|
359
|
+
*/
|
|
360
|
+
function renderSubtree(node, path, out) {
|
|
361
|
+
if (isSensitivePath(path)) return;
|
|
362
|
+
if (!isObject(node)) {
|
|
363
|
+
out.push(`${path}: ${renderLeaf(node)}`);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const visible = Object.entries(node).filter(([key]) => !key.startsWith("_"));
|
|
367
|
+
const entries = visible.filter(([key]) => !isSensitivePath(`${path}.${key}`));
|
|
368
|
+
// A withheld key is COUNTED, never silently dropped. Omitting an
|
|
369
|
+
// identity-shaped value while leaving no trace that it exists would recreate,
|
|
370
|
+
// one level down, the invisible-gap failure this whole hook is against.
|
|
371
|
+
const withheld = visible.length - entries.length;
|
|
372
|
+
const suffix =
|
|
373
|
+
withheld > 0 ? `, +${withheld} withheld (identity-shaped)` : "";
|
|
374
|
+
const leaves = entries.filter(([, value]) => !isObject(value));
|
|
375
|
+
const branches = entries.filter(([, value]) => isObject(value));
|
|
376
|
+
if (leaves.length > 0 || withheld > 0) {
|
|
377
|
+
const shown = leaves.slice(0, MAX_CHILDREN);
|
|
378
|
+
const rest = leaves.length - shown.length;
|
|
379
|
+
const rendered = shown
|
|
380
|
+
.map(([key, value]) => `${key}=${renderLeaf(value)}`)
|
|
381
|
+
.join(", ");
|
|
382
|
+
out.push(
|
|
383
|
+
`${path}: ${rendered}${rest > 0 ? `, +${rest} more` : ""}${suffix}`
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
for (const [key, value] of branches)
|
|
387
|
+
renderSubtree(value, `${path}.${key}`, out);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* The level a gate declares at one moment, whichever shape it used.
|
|
392
|
+
*
|
|
393
|
+
* A moment is either a bare level (`"required"`) or an object carrying `level`
|
|
394
|
+
* alongside `run`/`await`. Both spellings are live in shipped configs.
|
|
395
|
+
* @param {unknown} declaration What the gate declares at that moment.
|
|
396
|
+
* @returns {string|null} The level, or null when this is not a moment.
|
|
397
|
+
*/
|
|
398
|
+
function levelOf(declaration) {
|
|
399
|
+
const declared =
|
|
400
|
+
typeof declaration === "string"
|
|
401
|
+
? declaration
|
|
402
|
+
: isObject(declaration) && typeof declaration.level === "string"
|
|
403
|
+
? declaration.level
|
|
404
|
+
: null;
|
|
405
|
+
return declared !== null && LEVELS.has(declared) ? declared : null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Render the gates block grouped by moment and level.
|
|
410
|
+
*
|
|
411
|
+
* Gates are the largest block in a mature config and the one an agent most
|
|
412
|
+
* often acts against, so truncating the list would drop the answer to the
|
|
413
|
+
* question actually being asked — "what must be green before I push?".
|
|
414
|
+
* Grouping answers it in a handful of lines instead, with nothing elided.
|
|
415
|
+
* @param {unknown} gates The resolved `gates` block.
|
|
416
|
+
* @returns {string[]} Rendered lines.
|
|
417
|
+
*/
|
|
418
|
+
function renderGates(gates) {
|
|
419
|
+
if (!isObject(gates)) return [];
|
|
420
|
+
/** @type {Map<string, string[]>} */
|
|
421
|
+
const grouped = new Map();
|
|
422
|
+
let count = 0;
|
|
423
|
+
for (const [gateId, declaration] of Object.entries(gates)) {
|
|
424
|
+
if (
|
|
425
|
+
gateId === "runner" ||
|
|
426
|
+
gateId === "unproven" ||
|
|
427
|
+
!isObject(declaration)
|
|
428
|
+
) {
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
count += 1;
|
|
432
|
+
for (const [moment, momentDeclaration] of Object.entries(declaration)) {
|
|
433
|
+
const level = levelOf(momentDeclaration);
|
|
434
|
+
if (level === null) continue;
|
|
435
|
+
const bucket = `${moment} ${level}`;
|
|
436
|
+
grouped.set(bucket, [...(grouped.get(bucket) ?? []), gateId]);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (count === 0) return [];
|
|
440
|
+
return [
|
|
441
|
+
`gates (${count} declared) — a gate below is enforced at that moment; do not skip, weaken or bypass one:`,
|
|
442
|
+
...[...grouped.entries()]
|
|
443
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
444
|
+
.map(([bucket, ids]) => ` ${bucket}: ${ids.join(", ")}`),
|
|
445
|
+
];
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Render the declared-vs-defaulted lines.
|
|
450
|
+
* @param {object} config Resolved config.
|
|
451
|
+
* @returns {string[]} Rendered lines, empty when everything was declared.
|
|
452
|
+
*/
|
|
453
|
+
function renderDefaults(config) {
|
|
454
|
+
const lines = [];
|
|
455
|
+
for (const { path, value, owner } of BUILT_IN_DEFAULTS) {
|
|
456
|
+
if (at(config, path) !== undefined) continue;
|
|
457
|
+
lines.push(
|
|
458
|
+
`${path}: ${value} [Lisa built-in default — this project declares none; owner: ${owner}]`
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
for (const path of REQUIRED_KEYS) {
|
|
462
|
+
if (at(config, path) !== undefined) continue;
|
|
463
|
+
lines.push(
|
|
464
|
+
`${path}: NOT DECLARED — Lisa has no default for this. Flows that dispatch on it stop rather than guess.`
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
return lines;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Render the whole value body, honouring {@link CONTEXT_BUDGET}.
|
|
472
|
+
* @param {object} config Resolved config.
|
|
473
|
+
* @returns {string} The rendered body.
|
|
474
|
+
*/
|
|
475
|
+
function renderBody(config) {
|
|
476
|
+
/** @type {string[]} */
|
|
477
|
+
const lines = [];
|
|
478
|
+
const seen = new Set();
|
|
479
|
+
for (const key of SUBTREE_ORDER) {
|
|
480
|
+
if (!(key in config)) continue;
|
|
481
|
+
seen.add(key);
|
|
482
|
+
if (key === "gates") {
|
|
483
|
+
const gates = config.gates;
|
|
484
|
+
const runner = isObject(gates) ? gates.runner : undefined;
|
|
485
|
+
const unproven = isObject(gates) ? gates.unproven : undefined;
|
|
486
|
+
if (runner !== undefined)
|
|
487
|
+
lines.push(`gates.runner: ${renderLeaf(runner)}`);
|
|
488
|
+
if (unproven !== undefined) {
|
|
489
|
+
lines.push(`gates.unproven: ${renderLeaf(unproven)}`);
|
|
490
|
+
}
|
|
491
|
+
lines.push(...renderGates(gates));
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
renderSubtree(config[key], key, lines);
|
|
495
|
+
}
|
|
496
|
+
const unlisted = Object.keys(config).filter(
|
|
497
|
+
key => !seen.has(key) && !key.startsWith("_")
|
|
498
|
+
);
|
|
499
|
+
if (unlisted.length > 0) {
|
|
500
|
+
lines.push(
|
|
501
|
+
`other declared keys (not summarised here): ${unlisted.join(", ")}`
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
lines.push(...renderDefaults(config));
|
|
505
|
+
return withinBudget(lines);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Join lines, stopping at the budget and counting what did not fit.
|
|
510
|
+
* @param {string[]} lines Rendered lines.
|
|
511
|
+
* @returns {string} The joined body.
|
|
512
|
+
*/
|
|
513
|
+
function withinBudget(lines) {
|
|
514
|
+
const kept = [];
|
|
515
|
+
let size = 0;
|
|
516
|
+
for (const raw of lines) {
|
|
517
|
+
// Scrubbed BEFORE truncation: a cut through the middle of an identity would
|
|
518
|
+
// leave a fragment `EMAIL_SHAPED` no longer matches, so redacting the
|
|
519
|
+
// joined body afterwards would be redacting text that had already escaped.
|
|
520
|
+
const scrubbed = scrub(raw);
|
|
521
|
+
const line =
|
|
522
|
+
scrubbed.length > MAX_LINE
|
|
523
|
+
? `${scrubbed.slice(0, MAX_LINE)}… (line truncated)`
|
|
524
|
+
: scrubbed;
|
|
525
|
+
if (size + line.length > CONTEXT_BUDGET) {
|
|
526
|
+
kept.push(
|
|
527
|
+
`… ${lines.length - kept.length} further line(s) omitted to stay within the session-context budget. Read ${MAIN_CONFIG} for the rest.`
|
|
528
|
+
);
|
|
529
|
+
break;
|
|
530
|
+
}
|
|
531
|
+
kept.push(line);
|
|
532
|
+
size += line.length + 1;
|
|
533
|
+
}
|
|
534
|
+
return kept.join("\n");
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Opening tag of the injected block. Tests and readers key off it. */
|
|
538
|
+
const OPEN_TAG = "<lisa-resolved-config>";
|
|
539
|
+
/** Closing tag of the injected block. */
|
|
540
|
+
const CLOSE_TAG = "</lisa-resolved-config>";
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Wrap a body in the block's tags and preamble.
|
|
544
|
+
* @param {string} body Rendered body.
|
|
545
|
+
* @returns {string} The full injected block.
|
|
546
|
+
*/
|
|
547
|
+
function block(body) {
|
|
548
|
+
return `${OPEN_TAG}\n${body}\n${CLOSE_TAG}`;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Build the injected block for a project directory.
|
|
553
|
+
* @param {string} projectDir Absolute path to the project root.
|
|
554
|
+
* @returns {string} The block to inject.
|
|
555
|
+
*/
|
|
556
|
+
export function buildContext(projectDir) {
|
|
557
|
+
const main = readJson(join(projectDir, MAIN_CONFIG));
|
|
558
|
+
const local = readJson(join(projectDir, LOCAL_CONFIG));
|
|
559
|
+
|
|
560
|
+
if (main.state === "bad" || local.state === "bad") {
|
|
561
|
+
const which = main.state === "bad" ? MAIN_CONFIG : LOCAL_CONFIG;
|
|
562
|
+
const reason = main.state === "bad" ? main.reason : local.reason;
|
|
563
|
+
return block(
|
|
564
|
+
`Lisa configuration could not be read: ${which} (${scrub(reason)}).\n` +
|
|
565
|
+
`Treat every Lisa-configured value as UNKNOWN. Do not assume a default, and do not ` +
|
|
566
|
+
`report this project as unconfigured — the file exists and is broken, which is a ` +
|
|
567
|
+
`different fact. Fixing it is the first task.`
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (main.state === "absent" && local.state === "absent") {
|
|
572
|
+
return block(
|
|
573
|
+
`No Lisa configuration found: neither ${MAIN_CONFIG} nor ${LOCAL_CONFIG} exists here.\n` +
|
|
574
|
+
`Nothing declares this project's tracker, gates, or deploy branches, so any Lisa flow ` +
|
|
575
|
+
`that dispatches on them will stop rather than guess. Run the matching \`/lisa:setup:*\` ` +
|
|
576
|
+
`skill to create one. Do not infer a tracker from the shape of a request.`
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const resolved = deepMerge(
|
|
581
|
+
main.state === "ok" ? main.value : {},
|
|
582
|
+
local.state === "ok" ? local.value : {}
|
|
583
|
+
);
|
|
584
|
+
const sources =
|
|
585
|
+
local.state === "ok"
|
|
586
|
+
? `${MAIN_CONFIG} + ${LOCAL_CONFIG} (local wins where they overlap)`
|
|
587
|
+
: `${MAIN_CONFIG} (no local override file)`;
|
|
588
|
+
|
|
589
|
+
return block(
|
|
590
|
+
`These are this project's EFFECTIVE Lisa configuration values, already resolved from ${sources}.\n` +
|
|
591
|
+
`Act on them directly. Do not re-read the config files to learn them, and do not act ` +
|
|
592
|
+
`against them. A line marked [Lisa built-in default] is NOT something this project ` +
|
|
593
|
+
`declared. Identity- and credential-shaped values are omitted by design.\n\n` +
|
|
594
|
+
renderBody(resolved)
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Read the hook event name from the harness's stdin payload.
|
|
600
|
+
* @param {string} raw Raw stdin.
|
|
601
|
+
* @returns {string} The event name, defaulting to `SessionStart`.
|
|
602
|
+
*/
|
|
603
|
+
function eventName(raw) {
|
|
604
|
+
try {
|
|
605
|
+
const parsed = JSON.parse(raw);
|
|
606
|
+
return typeof parsed?.hook_event_name === "string"
|
|
607
|
+
? parsed.hook_event_name
|
|
608
|
+
: "SessionStart";
|
|
609
|
+
} catch {
|
|
610
|
+
return "SessionStart";
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Emit the hook envelope. Always exits 0 — see the module comment.
|
|
616
|
+
* @param {string[]} argv Process arguments.
|
|
617
|
+
* @param {string} stdin Raw stdin.
|
|
618
|
+
* @returns {string} The JSON envelope.
|
|
619
|
+
*/
|
|
620
|
+
export function run(argv, stdin) {
|
|
621
|
+
const flag = argv.indexOf("--project-dir");
|
|
622
|
+
const projectDir = flag >= 0 ? argv[flag + 1] : process.cwd();
|
|
623
|
+
return JSON.stringify({
|
|
624
|
+
hookSpecificOutput: {
|
|
625
|
+
hookEventName: eventName(stdin),
|
|
626
|
+
additionalContext: buildContext(projectDir),
|
|
627
|
+
},
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Whether this module is the process entry point.
|
|
633
|
+
*
|
|
634
|
+
* Both sides are realpath'd: comparing `fileURLToPath(import.meta.url)` against
|
|
635
|
+
* `path.resolve(process.argv[1])` disagrees whenever the checkout is reached
|
|
636
|
+
* through a symlink — a git worktree, or any `/tmp` path on macOS, `/tmp` being
|
|
637
|
+
* a symlink to `/private/tmp` — because `import.meta.url` is the REAL path
|
|
638
|
+
* while `argv[1]` is whatever the caller typed.
|
|
639
|
+
*
|
|
640
|
+
* The one implementation lives at `scripts/lib/invoked-as-script.mjs`. This
|
|
641
|
+
* file cannot import it: it is materialized into every plugin payload, where
|
|
642
|
+
* there is no `./lib/` to import from — the same accommodation
|
|
643
|
+
* `failure-signature-index.mjs` and `threshold-ratchet.mjs` make.
|
|
644
|
+
* @param {string} moduleUrl The caller's own `import.meta.url`.
|
|
645
|
+
* @param {string | undefined} [argv1] Entry path; defaults to `process.argv[1]`.
|
|
646
|
+
* @returns {boolean} Whether the caller should run its CLI body.
|
|
647
|
+
*/
|
|
648
|
+
export function invokedAsScript(moduleUrl, argv1 = process.argv[1]) {
|
|
649
|
+
if (!argv1) return false;
|
|
650
|
+
try {
|
|
651
|
+
return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
|
|
652
|
+
} catch {
|
|
653
|
+
return false;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (invokedAsScript(import.meta.url)) {
|
|
658
|
+
let stdin = "";
|
|
659
|
+
try {
|
|
660
|
+
stdin = readFileSync(0, "utf8");
|
|
661
|
+
} catch {
|
|
662
|
+
stdin = "";
|
|
663
|
+
}
|
|
664
|
+
try {
|
|
665
|
+
process.stdout.write(`${run(process.argv.slice(2), stdin)}\n`);
|
|
666
|
+
} catch {
|
|
667
|
+
// Fail soft: a hook that cannot render must never wedge session start.
|
|
668
|
+
}
|
|
669
|
+
}
|