@codyswann/lisa 4.31.1 → 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.
Files changed (75) hide show
  1. package/cdk/create-only/stryker.conf.json +44 -0
  2. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  3. package/dist/core/lisa-owned-hash-ledger.js +22 -0
  4. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  5. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  6. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  7. package/dist/core/upstream-evidence-manifest.js +18 -2
  8. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  9. package/package.json +4 -4
  10. package/plugins/lisa/.claude-plugin/plugin.json +19 -1
  11. package/plugins/lisa/.codex-plugin/hooks.json +18 -0
  12. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  13. package/plugins/lisa/hooks/inject-resolved-config.mjs +669 -0
  14. package/plugins/lisa/hooks/inject-resolved-config.sh +48 -0
  15. package/plugins/lisa/rules/eager/config-resolution.md +8 -1
  16. package/plugins/lisa-agy/plugin.json +1 -1
  17. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  19. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  20. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-copilot/.claude-plugin/plugin.json +10 -1
  23. package/plugins/lisa-copilot/hooks/inject-resolved-config.mjs +669 -0
  24. package/plugins/lisa-copilot/hooks/inject-resolved-config.sh +48 -0
  25. package/plugins/lisa-copilot/rules/eager/config-resolution.md +8 -1
  26. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-cursor/hooks/hooks.json +6 -0
  28. package/plugins/lisa-cursor/hooks/inject-resolved-config.mjs +669 -0
  29. package/plugins/lisa-cursor/hooks/inject-resolved-config.sh +48 -0
  30. package/plugins/lisa-cursor/rules/config-resolution.mdc +8 -1
  31. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  33. package/plugins/lisa-expo-agy/plugin.json +1 -1
  34. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  38. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  39. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  43. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  44. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  48. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  49. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  53. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  54. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  58. package/plugins/lisa-rails-agy/plugin.json +1 -1
  59. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  63. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  64. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  68. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  69. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  71. package/plugins/src/base/.claude-plugin/plugin.json +18 -0
  72. package/plugins/src/base/hooks/inject-resolved-config.mjs +669 -0
  73. package/plugins/src/base/hooks/inject-resolved-config.sh +48 -0
  74. package/plugins/src/base/rules/eager/config-resolution.md +8 -1
  75. 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
+ }