@codyswann/lisa 4.32.0 → 4.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  2. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  3. package/dist/core/upstream-evidence-manifest.js +3 -2
  4. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  5. package/package.json +4 -4
  6. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  7. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  8. package/plugins/lisa/hooks/inject-resolved-config.mjs +224 -17
  9. package/plugins/lisa/hooks/inject-resolved-config.sh +28 -0
  10. package/plugins/lisa-agy/plugin.json +1 -1
  11. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  12. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  13. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  14. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  15. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  17. package/plugins/lisa-copilot/hooks/inject-resolved-config.mjs +224 -17
  18. package/plugins/lisa-copilot/hooks/inject-resolved-config.sh +28 -0
  19. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-cursor/hooks/inject-resolved-config.mjs +224 -17
  21. package/plugins/lisa-cursor/hooks/inject-resolved-config.sh +28 -0
  22. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  24. package/plugins/lisa-expo-agy/plugin.json +1 -1
  25. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  29. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  30. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  34. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  35. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  39. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  40. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  44. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  45. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  49. package/plugins/lisa-rails-agy/plugin.json +1 -1
  50. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  54. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  55. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  59. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  60. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  62. package/plugins/src/base/hooks/inject-resolved-config.mjs +224 -17
  63. package/plugins/src/base/hooks/inject-resolved-config.sh +28 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "4.32.0",
3
+ "version": "4.32.1",
4
4
  "description": "Ruby on Rails-specific skills and hooks for RuboCop and ast-grep scanning on edit.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "4.32.0",
3
+ "version": "4.32.1",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "4.32.0",
3
+ "version": "4.32.1",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "4.32.0",
3
+ "version": "4.32.1",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.32.0",
3
+ "version": "4.32.1",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.32.0",
3
+ "version": "4.32.1",
4
4
  "description": "TypeScript-specific hooks for formatting, linting, and ast-grep scanning on edit.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.32.0",
3
+ "version": "4.32.1",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.32.0",
3
+ "version": "4.32.1",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.32.0",
3
+ "version": "4.32.1",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.32.0",
3
+ "version": "4.32.1",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.32.0",
3
+ "version": "4.32.1",
4
4
  "description": "Distributable LLM Wiki kernel — ingest, query, lint, and maintain a git-native markdown knowledge base across Claude and Codex.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.32.0",
3
+ "version": "4.32.1",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.32.0",
3
+ "version": "4.32.1",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.32.0",
3
+ "version": "4.32.1",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -116,27 +116,39 @@ const SENSITIVE_WORDS = new Set([
116
116
  "actor",
117
117
  "actors",
118
118
  "apikey",
119
+ "arn",
119
120
  "auth",
121
+ "authorization",
120
122
  "bearer",
123
+ "cert",
124
+ "certificate",
125
+ "connection",
121
126
  "cookie",
122
127
  "credential",
123
128
  "credentials",
124
129
  "dsn",
125
130
  "email",
126
131
  "identity",
132
+ "jwt",
127
133
  "key",
128
134
  "keys",
129
135
  "login",
130
136
  "mail",
137
+ "mfa",
131
138
  "oauth",
139
+ "otp",
132
140
  "passphrase",
133
141
  "passwd",
134
142
  "password",
135
143
  "pat",
144
+ "pem",
145
+ "pin",
146
+ "private",
136
147
  "secret",
137
148
  "session",
138
149
  "signature",
139
150
  "token",
151
+ "totp",
140
152
  "user",
141
153
  "username",
142
154
  "webhook",
@@ -156,6 +168,36 @@ const SENSITIVE_WORDS = new Set([
156
168
  */
157
169
  const EMAIL_SHAPED = /[^\s@,=]+@[^\s@,=]+\.[^\s@,=]+/g;
158
170
 
171
+ /**
172
+ * Value shapes that are a credential whatever key they arrived under.
173
+ *
174
+ * {@link SENSITIVE_WORDS} can only ever cover key names somebody thought of.
175
+ * {@link SUBTREE_ORDER} is a SUBTREE allowlist, so a key added under an
176
+ * already-listed parent reaches every session with nobody reviewing it — which
177
+ * is the case these patterns exist for, and the reason the preamble's sentence
178
+ * was made to describe this mechanism rather than promise more than it has.
179
+ *
180
+ * Each pattern is anchored on a structural marker (a scheme with a
181
+ * `user:password@` userinfo, a vendor key prefix, three base64url segments),
182
+ * never on entropy. An entropy heuristic would redact ids a config legitimately
183
+ * carries — routine ids, revisions, digests — and this block exists to make
184
+ * configuration visible, so a false redaction is a real cost, not a free one.
185
+ */
186
+ const CREDENTIAL_SHAPED = [
187
+ // A private key pasted into a config value.
188
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
189
+ // Credentials in a connection string's userinfo. The whole URL goes: the
190
+ // host is not worth keeping at the price of splicing a line back together
191
+ // around the part that was cut out.
192
+ /\b[a-z][a-z0-9+.-]*:\/\/[^\s:@,=]+:[^\s@,=]+@[^\s,=]*/g,
193
+ // Vendor-prefixed API keys.
194
+ /\b(?:[a-z]{2}_(?:live|test)_[A-Za-z0-9]{8,}|gh[pousr]_[A-Za-z0-9]{16,}|xox[abprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{12,}|AIza[0-9A-Za-z_-]{20,}|sk-[A-Za-z0-9_-]{16,})\b/g,
195
+ // A signed token: three base64url segments, header first.
196
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g,
197
+ // An ARN carries an account number in its fifth field.
198
+ /\barn:[a-z0-9-]*:[^\s,=]*/g,
199
+ ];
200
+
159
201
  /** What replaces a redacted value, so the KEY's existence is still visible. */
160
202
  const REDACTED = "[redacted]";
161
203
 
@@ -168,9 +210,15 @@ const REDACTED = "[redacted]";
168
210
  * arbitrary host project — and `tests/unit/hooks/inject-resolved-config.test.ts`
169
211
  * imports the real constants and asserts this table still agrees with them, so
170
212
  * the copy cannot drift silently.
213
+ *
214
+ * EXPORTED so that test can compare PATH-TO-VALUE PAIRS. Asserting instead that
215
+ * each constant appears somewhere in this file pins only the set of values
216
+ * present: swapping the `harness` and `gates.unproven` entries preserves that
217
+ * set, leaves every test in both suites green, and tells every session with no
218
+ * declared harness that its harness is `warn`.
171
219
  * @type {ReadonlyArray<{path: string, value: string, owner: string}>}
172
220
  */
173
- const BUILT_IN_DEFAULTS = [
221
+ export const BUILT_IN_DEFAULTS = [
174
222
  { path: "harness", value: "claude", owner: "src/core/config.ts" },
175
223
  {
176
224
  path: "gates.runner",
@@ -256,12 +304,15 @@ function isSensitivePath(path) {
256
304
  }
257
305
 
258
306
  /**
259
- * Redact anything email-shaped in already-rendered text.
307
+ * Redact anything identity- or credential-shaped in already-rendered text.
260
308
  * @param {string} text Rendered line or block.
261
- * @returns {string} The text with identity-shaped values replaced.
309
+ * @returns {string} The text with those values replaced.
262
310
  */
263
311
  function scrub(text) {
264
- return text.replace(EMAIL_SHAPED, REDACTED);
312
+ return CREDENTIAL_SHAPED.reduce(
313
+ (carried, pattern) => carried.replace(pattern, REDACTED),
314
+ text.replace(EMAIL_SHAPED, REDACTED)
315
+ );
265
316
  }
266
317
 
267
318
  /**
@@ -273,6 +324,35 @@ function isObject(value) {
273
324
  return typeof value === "object" && value !== null && !Array.isArray(value);
274
325
  }
275
326
 
327
+ /**
328
+ * Keys that reach an object's prototype rather than its own properties.
329
+ *
330
+ * `JSON.parse` makes `__proto__` an OWN property, so a later `merged[key] =`
331
+ * hands it to the prototype setter — and `key in config` and `node[key]` both
332
+ * read the prototype chain. A local override file carrying nothing but a
333
+ * `__proto__` object could therefore make this block announce a gate that no
334
+ * file declares, while the gate runner (which destructures own properties)
335
+ * enforced something else. The injected view is the one an agent is told to
336
+ * act on, so it is the one that must not be forgeable.
337
+ */
338
+ const PROTOTYPE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
339
+
340
+ /**
341
+ * Strip prototype-reaching keys from parsed JSON, at every depth.
342
+ * @param {unknown} value A parsed JSON value.
343
+ * @returns {unknown} The same value with {@link PROTOTYPE_KEYS} removed.
344
+ */
345
+ function withoutPrototypeKeys(value) {
346
+ if (Array.isArray(value)) return value.map(withoutPrototypeKeys);
347
+ if (!isObject(value)) return value;
348
+ const out = {};
349
+ for (const [key, child] of Object.entries(value)) {
350
+ if (PROTOTYPE_KEYS.has(key)) continue;
351
+ out[key] = withoutPrototypeKeys(child);
352
+ }
353
+ return out;
354
+ }
355
+
276
356
  /**
277
357
  * Deep-merge the local override onto the committed config.
278
358
  *
@@ -290,11 +370,41 @@ function deepMerge(base, override) {
290
370
  if (!isObject(base) || !isObject(override)) return override;
291
371
  const merged = { ...base };
292
372
  for (const [key, value] of Object.entries(override)) {
373
+ // Belt and braces: `readJson` has already stripped these, and this loop is
374
+ // the assignment that would otherwise reach the prototype setter.
375
+ if (PROTOTYPE_KEYS.has(key)) continue;
293
376
  merged[key] = deepMerge(base[key], value);
294
377
  }
295
378
  return merged;
296
379
  }
297
380
 
381
+ /**
382
+ * Why a file could not be read, said without quoting the file or the disk.
383
+ *
384
+ * A `JSON.parse` failure message is not safe to repeat. V8 embeds roughly the
385
+ * first ten characters of the offending input in one of its forms — the entire
386
+ * file when the file is short — and this path runs against the gitignored local
387
+ * override, the one file identity is supposed to live in. There are no keys at
388
+ * that point, so the key-name filter cannot help. An `fs` failure message is
389
+ * not safe either: it carries the absolute path.
390
+ *
391
+ * Only the position is kept, because a line and column number describe the
392
+ * file's SHAPE and can be acted on, while quoting its contents adds nothing a
393
+ * reader with the file in front of them does not already have.
394
+ * @param {unknown} err What was thrown.
395
+ * @returns {string} A reason carrying no content and no path.
396
+ */
397
+ function safeReason(err) {
398
+ const code = isObject(err) ? err.code : undefined;
399
+ if (typeof code === "string") return `${code} while reading the file`;
400
+ const message =
401
+ isObject(err) && typeof err.message === "string" ? err.message : "";
402
+ const position = /at position \d+(?: \(line \d+ column \d+\))?/.exec(message);
403
+ return position === null
404
+ ? "not valid JSON"
405
+ : `not valid JSON ${position[0].replace("at position", "at byte")}`;
406
+ }
407
+
298
408
  /**
299
409
  * Read one JSON file.
300
410
  * @param {string} path Absolute path.
@@ -309,9 +419,9 @@ function readJson(path) {
309
419
  if (!isObject(parsed)) {
310
420
  return { state: "bad", reason: "root is not a JSON object" };
311
421
  }
312
- return { state: "ok", value: parsed };
422
+ return { state: "ok", value: withoutPrototypeKeys(parsed) };
313
423
  } catch (err) {
314
- return { state: "bad", reason: err.message };
424
+ return { state: "bad", reason: safeReason(err) };
315
425
  }
316
426
  }
317
427
 
@@ -405,6 +515,41 @@ function levelOf(declaration) {
405
515
  return declared !== null && LEVELS.has(declared) ? declared : null;
406
516
  }
407
517
 
518
+ /**
519
+ * Render one moment-and-level bucket across as many lines as its ids need.
520
+ *
521
+ * The generic {@link MAX_LINE} cut was being applied to the one line this
522
+ * module promises never elides: at 30 gates in a single bucket, six ids
523
+ * vanished behind `… (line truncated)` while the header above still counted
524
+ * them and the words beside it said not to bypass one. Wrapping is what keeps
525
+ * that promise — a count of what was dropped would still be an agent told the
526
+ * name of 24 gates out of 30 it must not skip.
527
+ * @param {string} bucket The `moment level` label.
528
+ * @param {string[]} ids Gate ids declared at that bucket.
529
+ * @returns {string[]} Lines, each within {@link MAX_LINE}.
530
+ */
531
+ function bucketLines(bucket, ids) {
532
+ const lines = [];
533
+ let prefix = ` ${bucket}: `;
534
+ let current = [];
535
+ const flush = () => {
536
+ if (current.length === 0) return;
537
+ lines.push(`${prefix}${current.join(", ")}`);
538
+ prefix = ` ${bucket} (cont.): `;
539
+ current = [];
540
+ };
541
+ for (const id of ids) {
542
+ const width =
543
+ prefix.length +
544
+ current.reduce((carried, held) => carried + held.length + 2, 0) +
545
+ id.length;
546
+ if (current.length > 0 && width > MAX_LINE) flush();
547
+ current.push(id);
548
+ }
549
+ flush();
550
+ return lines;
551
+ }
552
+
408
553
  /**
409
554
  * Render the gates block grouped by moment and level.
410
555
  *
@@ -441,7 +586,7 @@ function renderGates(gates) {
441
586
  `gates (${count} declared) — a gate below is enforced at that moment; do not skip, weaken or bypass one:`,
442
587
  ...[...grouped.entries()]
443
588
  .sort(([a], [b]) => a.localeCompare(b))
444
- .map(([bucket, ids]) => ` ${bucket}: ${ids.join(", ")}`),
589
+ .flatMap(([bucket, ids]) => bucketLines(bucket, ids)),
445
590
  ];
446
591
  }
447
592
 
@@ -473,8 +618,25 @@ function renderDefaults(config) {
473
618
  * @returns {string} The rendered body.
474
619
  */
475
620
  function renderBody(config) {
621
+ // The gap lines go FIRST, and that ordering is the whole point rather than a
622
+ // presentation choice. `withinBudget` truncates from the end, so appending
623
+ // them last meant the budget ate them first: a config with a large `policy`
624
+ // block and no `tracker` rendered ZERO `NOT DECLARED` lines and zero
625
+ // built-in-default lines — "a gap has no symptom at all", the failure this
626
+ // hook was built against, reproduced inside the hook. A value that does not
627
+ // fit can be looked up in the file. An absence cannot be looked up anywhere,
628
+ // so it is the line that must survive.
476
629
  /** @type {string[]} */
477
- const lines = [];
630
+ const lines = renderDefaults(config);
631
+ const gapLines = lines.length;
632
+ // The gates block is the one this module promises never elides, so the lines
633
+ // it produced are named here rather than inferred from their shape later.
634
+ // `bucketLines` keeps every line inside MAX_LINE by wrapping, but it cannot
635
+ // wrap a SINGLE id longer than the line — nothing splits an identifier — so
636
+ // without this the generic cut would come back for exactly the id that most
637
+ // needs reading in full.
638
+ /** @type {Set<string>} */
639
+ const unelidable = new Set();
478
640
  const seen = new Set();
479
641
  for (const key of SUBTREE_ORDER) {
480
642
  if (!(key in config)) continue;
@@ -488,7 +650,9 @@ function renderBody(config) {
488
650
  if (unproven !== undefined) {
489
651
  lines.push(`gates.unproven: ${renderLeaf(unproven)}`);
490
652
  }
491
- lines.push(...renderGates(gates));
653
+ const gateLines = renderGates(gates);
654
+ for (const gateLine of gateLines) unelidable.add(gateLine);
655
+ lines.push(...gateLines);
492
656
  continue;
493
657
  }
494
658
  renderSubtree(config[key], key, lines);
@@ -501,16 +665,47 @@ function renderBody(config) {
501
665
  `other declared keys (not summarised here): ${unlisted.join(", ")}`
502
666
  );
503
667
  }
504
- lines.push(...renderDefaults(config));
505
- return withinBudget(lines);
668
+ return withinBudget(lines, { unelidable, gapLines });
669
+ }
670
+
671
+ /**
672
+ * The overflow notice, for a body that kept `keptCount` of `total` lines.
673
+ *
674
+ * Built from the counts rather than held as a constant because the notice is
675
+ * itself part of the body it describes: making room for it changes how many
676
+ * lines were dropped, which changes the sentence, which changes how much room
677
+ * it needs. See {@link withinBudget}.
678
+ * @param {number} total How many lines were rendered in all.
679
+ * @param {number} keptCount How many of them survived the budget.
680
+ * @param {number} gapLines How many leading lines describe gaps and defaults.
681
+ * @returns {string} The notice line.
682
+ */
683
+ function overflowNotice(total, keptCount, gapLines) {
684
+ // Two things this notice used to get wrong. It counted LINES while reading as
685
+ // if it counted keys — one line carries many leaves — and it sent the reader
686
+ // to the config file for lines that no file can answer. The second is fixed
687
+ // by ordering (`renderBody` renders the declared-vs-default lines first, so
688
+ // they are never the omitted ones); saying so is what makes the advice true.
689
+ // The claim stays CONDITIONAL, because a config whose gap lines alone
690
+ // exhaust the budget would otherwise have this sentence assert a
691
+ // completeness the body does not have — the exact defect this hook exists to
692
+ // remove, committed by the sentence announcing it.
693
+ const completeness =
694
+ keptCount >= gapLines
695
+ ? `The declared-vs-default lines above are complete — read ${MAIN_CONFIG} and ${LOCAL_CONFIG} for the omitted values.`
696
+ : `The declared-vs-default lines above are NOT complete, so a gap may be missing entirely — read ${MAIN_CONFIG} and ${LOCAL_CONFIG}.`;
697
+ return `… ${total - keptCount} further rendered line(s) omitted to stay within the session-context budget; a line can carry several keys, so more keys than that are unrendered. ${completeness}`;
506
698
  }
507
699
 
508
700
  /**
509
701
  * Join lines, stopping at the budget and counting what did not fit.
510
702
  * @param {string[]} lines Rendered lines.
703
+ * @param {object} [options] Rendering constraints.
704
+ * @param {Set<string>} [options.unelidable] Lines the MAX_LINE cut may not touch.
705
+ * @param {number} [options.gapLines] How many leading lines describe gaps.
511
706
  * @returns {string} The joined body.
512
707
  */
513
- function withinBudget(lines) {
708
+ function withinBudget(lines, { unelidable = new Set(), gapLines = 0 } = {}) {
514
709
  const kept = [];
515
710
  let size = 0;
516
711
  for (const raw of lines) {
@@ -519,13 +714,23 @@ function withinBudget(lines) {
519
714
  // joined body afterwards would be redacting text that had already escaped.
520
715
  const scrubbed = scrub(raw);
521
716
  const line =
522
- scrubbed.length > MAX_LINE
717
+ scrubbed.length > MAX_LINE && !unelidable.has(raw)
523
718
  ? `${scrubbed.slice(0, MAX_LINE)}… (line truncated)`
524
719
  : scrubbed;
525
720
  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
- );
721
+ // The notice has to fit INSIDE the budget, not be appended past it. It
722
+ // used to be pushed after the check, so a body that filled the budget
723
+ // exactly overshot it by the length of the sentence explaining that it
724
+ // had not. Lines come off the end until it fits, and the notice is
725
+ // rebuilt each time because dropping one is one more line to admit to.
726
+ while (
727
+ kept.length > 0 &&
728
+ size + overflowNotice(lines.length, kept.length, gapLines).length >
729
+ CONTEXT_BUDGET
730
+ ) {
731
+ size -= kept.pop().length + 1;
732
+ }
733
+ kept.push(overflowNotice(lines.length, kept.length, gapLines));
529
734
  break;
530
735
  }
531
736
  kept.push(line);
@@ -590,7 +795,9 @@ export function buildContext(projectDir) {
590
795
  `These are this project's EFFECTIVE Lisa configuration values, already resolved from ${sources}.\n` +
591
796
  `Act on them directly. Do not re-read the config files to learn them, and do not act ` +
592
797
  `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` +
798
+ `declared. A key whose NAME is identity- or credential-shaped is withheld and counted, ` +
799
+ `and a VALUE matching a known credential shape is replaced with ${REDACTED}: a ` +
800
+ `best-effort filter, not proof that anything it left alone is safe to repeat.\n\n` +
594
801
  renderBody(resolved)
595
802
  );
596
803
  }
@@ -44,5 +44,33 @@ if [ -z "${PROJECT_DIR}" ] || [ ! -d "${PROJECT_DIR}" ]; then
44
44
  PROJECT_DIR="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
45
45
  fi
46
46
 
47
+ # ...but the harness sets CLAUDE_PROJECT_DIR to wherever the session STARTED,
48
+ # which in a monorepo is routinely a subdirectory. Validating only that the
49
+ # directory exists left the git fallback above unreachable in exactly that case,
50
+ # so a fully configured repository reported "No Lisa configuration found" — the
51
+ # loudest possible wrong answer, in the one state this hook exists to make loud.
52
+ #
53
+ # So walk up for the config instead of assuming the declared directory holds it.
54
+ # The repository root bounds the walk: a config file ABOVE it belongs to some
55
+ # other project, and adopting it would be the same wrong answer wearing a
56
+ # different hat. `.git` is a file in a worktree and a directory otherwise, hence
57
+ # `-e`. The depth cap is a symlink-loop guard, not a policy.
58
+ config_root() {
59
+ dir="$1"
60
+ depth=0
61
+ while [ -n "${dir}" ] && [ "${dir}" != "/" ] && [ "${depth}" -lt 32 ]; do
62
+ if [ -f "${dir}/.lisa.config.json" ] || [ -f "${dir}/.lisa.config.local.json" ]; then
63
+ printf '%s' "${dir}"
64
+ return 0
65
+ fi
66
+ [ -e "${dir}/.git" ] && return 1
67
+ dir="$(dirname "${dir}")"
68
+ depth=$((depth + 1))
69
+ done
70
+ return 1
71
+ }
72
+
73
+ CONFIG_DIR="$(config_root "${PROJECT_DIR}")" && PROJECT_DIR="${CONFIG_DIR}"
74
+
47
75
  printf '%s' "$INPUT" | node "$RENDERER" --project-dir "$PROJECT_DIR" || true
48
76
  exit 0