@codyswann/lisa 2.106.3 → 2.106.5

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/package.json CHANGED
@@ -82,7 +82,7 @@
82
82
  "lodash": ">=4.18.1"
83
83
  },
84
84
  "name": "@codyswann/lisa",
85
- "version": "2.106.3",
85
+ "version": "2.106.5",
86
86
  "description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
87
87
  "main": "dist/index.js",
88
88
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.106.3",
3
+ "version": "2.106.5",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.106.3",
3
+ "version": "2.106.5",
4
4
  "description": "Universal governance: agents, skills, commands, hooks, and rules for all projects.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -7,7 +7,16 @@
7
7
  * diagnostic explains likely causes before an operator mutates the tree.
8
8
  */
9
9
  import { execFileSync } from "node:child_process";
10
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
10
+ import {
11
+ cpSync,
12
+ existsSync,
13
+ mkdtempSync,
14
+ readdirSync,
15
+ readFileSync,
16
+ rmSync,
17
+ statSync,
18
+ } from "node:fs";
19
+ import { tmpdir } from "node:os";
11
20
  import path from "node:path";
12
21
  import process from "node:process";
13
22
 
@@ -29,6 +38,7 @@ const GIT_BIN = "/usr/bin/git";
29
38
  * readonly classification: string
30
39
  * readonly path: string
31
40
  * readonly counterpart?: string
41
+ * readonly likelyCause: string
32
42
  * readonly nextAction: string
33
43
  * }} PluginSyncFinding
34
44
  *
@@ -50,8 +60,13 @@ export function explainPluginSync(root = process.cwd()) {
50
60
  const repoRoot = path.resolve(root);
51
61
  const statusBefore = gitStatus(repoRoot);
52
62
  const statusEntries = parseGitStatus(statusBefore);
63
+ const expectedGeneratedFiles = buildExpectedGeneratedFiles(repoRoot);
53
64
  const findings = [
54
- ...classifySourceGeneratedDrift(statusEntries),
65
+ ...classifySourceGeneratedDrift(
66
+ repoRoot,
67
+ statusEntries,
68
+ expectedGeneratedFiles
69
+ ),
55
70
  ...classifyMarketplaceDrift(repoRoot),
56
71
  ];
57
72
  const statusAfter = gitStatus(repoRoot);
@@ -97,6 +112,7 @@ export function renderPluginSyncReport(report) {
97
112
  lines.push(
98
113
  `- ${finding.classification}: ${finding.path}`,
99
114
  ` Evidence: ${finding.counterpart ? `${finding.path} -> ${finding.counterpart}` : finding.path}`,
115
+ ` Likely cause: ${finding.likelyCause}`,
100
116
  ` Next action: ${finding.nextAction}`
101
117
  );
102
118
  }
@@ -128,9 +144,10 @@ function highestClassification(findings) {
128
144
 
129
145
  /**
130
146
  * @param {readonly { readonly code: string, readonly file: string }[]} entries
147
+ * @param {ReadonlyMap<string, Buffer> | undefined} expectedGeneratedFiles
131
148
  * @returns {PluginSyncFinding[]}
132
149
  */
133
- function classifySourceGeneratedDrift(entries) {
150
+ function classifySourceGeneratedDrift(root, entries, expectedGeneratedFiles) {
134
151
  const changed = new Set(entries.map(entry => entry.file));
135
152
  const findings = [];
136
153
 
@@ -138,10 +155,24 @@ function classifySourceGeneratedDrift(entries) {
138
155
  const sourceCounterpart = sourceToBuilt(entry.file);
139
156
  if (sourceCounterpart) {
140
157
  const generatedChanged = changed.has(sourceCounterpart);
158
+ if (expectedGeneratedFiles) {
159
+ const expected = expectedGeneratedFiles.get(sourceCounterpart);
160
+ const active = readOptionalFile(root, sourceCounterpart);
161
+ const generatedMatchesSource =
162
+ expected !== undefined &&
163
+ active !== undefined &&
164
+ expected.equals(active);
165
+ if (generatedMatchesSource) {
166
+ continue;
167
+ }
168
+ }
141
169
  findings.push({
142
170
  classification: generatedChanged ? "OUT_OF_SYNC" : "SOURCE_NOT_BUILT",
143
171
  path: entry.file,
144
172
  counterpart: sourceCounterpart,
173
+ likelyCause: generatedChanged
174
+ ? "Both source and generated artifacts changed, but the generated artifact does not match a fresh plugin build."
175
+ : "A source plugin file changed, but its generated artifact was not rebuilt from plugins/src.",
145
176
  nextAction: generatedChanged
146
177
  ? "Review both source and generated changes, keep source authoritative, then run `bun run build:plugins && bun run check:plugins`."
147
178
  : "Run `bun run build:plugins`, then `bun run check:plugins`, and commit source plus regenerated artifacts.",
@@ -151,10 +182,23 @@ function classifySourceGeneratedDrift(entries) {
151
182
 
152
183
  const builtCounterpart = builtToSource(entry.file);
153
184
  if (builtCounterpart && !changed.has(builtCounterpart)) {
185
+ if (expectedGeneratedFiles) {
186
+ const expected = expectedGeneratedFiles.get(entry.file);
187
+ const active = readOptionalFile(root, entry.file);
188
+ if (
189
+ expected !== undefined &&
190
+ active !== undefined &&
191
+ expected.equals(active)
192
+ ) {
193
+ continue;
194
+ }
195
+ }
154
196
  findings.push({
155
197
  classification: "GENERATED_ONLY",
156
198
  path: entry.file,
157
199
  counterpart: builtCounterpart,
200
+ likelyCause:
201
+ "A generated plugin artifact changed without the matching plugins/src source change.",
158
202
  nextAction:
159
203
  "Move the edit to the matching plugins/src path, rebuild with `bun run build:plugins`, then run `bun run check:plugins`.",
160
204
  });
@@ -198,6 +242,8 @@ function classifyMarketplaceDrift(root) {
198
242
  classification: "MARKETPLACE_REGISTRATION_DRIFT",
199
243
  path: source.replace(/^\.\//, ""),
200
244
  counterpart: MARKETPLACE,
245
+ likelyCause:
246
+ "A built plugin directory exists but is not advertised by the marketplace manifest.",
201
247
  nextAction: `Add marketplace source "${source}" to \`${MARKETPLACE}\`, then run \`bun run check:plugins\`.`,
202
248
  });
203
249
  }
@@ -209,6 +255,8 @@ function classifyMarketplaceDrift(root) {
209
255
  classification: "MARKETPLACE_REGISTRATION_DRIFT",
210
256
  path: MARKETPLACE,
211
257
  counterpart: source.replace(/^\.\//, ""),
258
+ likelyCause:
259
+ "The marketplace manifest points at a built plugin directory that is missing.",
212
260
  nextAction:
213
261
  "Either restore the built plugin directory or remove the stale marketplace source, then run `bun run check:plugins`.",
214
262
  });
@@ -218,6 +266,113 @@ function classifyMarketplaceDrift(root) {
218
266
  return findings;
219
267
  }
220
268
 
269
+ /**
270
+ * Build plugins in a disposable copy and return the generated bytes that source
271
+ * changes should produce. If a minimal fixture cannot run the build script,
272
+ * callers fall back to status-based classification without mutating the repo.
273
+ * @param {string} root
274
+ * @returns {ReadonlyMap<string, Buffer> | undefined}
275
+ */
276
+ function buildExpectedGeneratedFiles(root) {
277
+ if (!existsSync(path.join(root, "scripts/build-plugins.sh"))) {
278
+ return undefined;
279
+ }
280
+
281
+ const tempRoot = mkdtempSync(path.join(tmpdir(), "lisa-plugin-sync-"));
282
+ const scratchRoot = path.join(tempRoot, "repo");
283
+ try {
284
+ cpSync(root, scratchRoot, {
285
+ recursive: true,
286
+ filter: src => shouldCopyToScratch(root, src),
287
+ });
288
+ execFileSync("bash", ["scripts/build-plugins.sh"], {
289
+ cwd: scratchRoot,
290
+ encoding: "utf8",
291
+ env: gitEnv(),
292
+ stdio: "ignore",
293
+ });
294
+ return collectGeneratedFiles(scratchRoot);
295
+ } catch {
296
+ return undefined;
297
+ } finally {
298
+ rmSync(tempRoot, { force: true, recursive: true });
299
+ }
300
+ }
301
+
302
+ /**
303
+ * @param {string} root
304
+ * @param {string} src
305
+ * @returns {boolean}
306
+ */
307
+ function shouldCopyToScratch(root, src) {
308
+ const rel = path.relative(root, src).split(path.sep).join("/");
309
+ if (rel === "") {
310
+ return true;
311
+ }
312
+ return !(
313
+ rel === ".git" ||
314
+ rel.startsWith(".git/") ||
315
+ rel === "node_modules" ||
316
+ rel.startsWith("node_modules/") ||
317
+ rel === "dist" ||
318
+ rel.startsWith("dist/") ||
319
+ rel === "coverage" ||
320
+ rel.startsWith("coverage/")
321
+ );
322
+ }
323
+
324
+ /**
325
+ * @param {string} root
326
+ * @returns {Map<string, Buffer>}
327
+ */
328
+ function collectGeneratedFiles(root) {
329
+ const files = new Map();
330
+ const pluginsRoot = path.join(root, PLUGINS_DIR);
331
+ if (!existsSync(pluginsRoot)) {
332
+ return files;
333
+ }
334
+ for (const name of readdirSync(pluginsRoot)) {
335
+ if (name === "src" || !name.startsWith("lisa")) {
336
+ continue;
337
+ }
338
+ collectFiles(root, path.join(pluginsRoot, name), files);
339
+ }
340
+ return files;
341
+ }
342
+
343
+ /**
344
+ * @param {string} root
345
+ * @param {string} current
346
+ * @param {Map<string, Buffer>} files
347
+ */
348
+ function collectFiles(root, current, files) {
349
+ const stats = statSync(current);
350
+ if (stats.isDirectory()) {
351
+ for (const child of readdirSync(current)) {
352
+ collectFiles(root, path.join(current, child), files);
353
+ }
354
+ return;
355
+ }
356
+ if (stats.isFile()) {
357
+ files.set(
358
+ path.relative(root, current).split(path.sep).join("/"),
359
+ readFileSync(current)
360
+ );
361
+ }
362
+ }
363
+
364
+ /**
365
+ * @param {string} root
366
+ * @param {string} rel
367
+ * @returns {Buffer | undefined}
368
+ */
369
+ function readOptionalFile(root, rel) {
370
+ const abs = path.join(root, rel);
371
+ return existsSync(abs) && statSync(abs).isFile()
372
+ ? readFileSync(abs)
373
+ : undefined;
374
+ }
375
+
221
376
  /**
222
377
  * @param {string} root
223
378
  * @returns {string}
@@ -229,7 +384,7 @@ function gitStatus(root) {
229
384
  {
230
385
  cwd: root,
231
386
  encoding: "utf8",
232
- env: gitEnv(),
387
+ env: gitEnv(root),
233
388
  }
234
389
  );
235
390
  }
@@ -308,15 +463,41 @@ function dedupeFindings(findings) {
308
463
 
309
464
  /**
310
465
  * Remove parent-hook Git environment so diagnostics inspect the requested repo.
466
+ * Bare common-dir worktrees in Codex need explicit GIT_DIR/GIT_WORK_TREE.
467
+ * @param {string} [root] Repository path the nested git command should inspect.
311
468
  * @returns {NodeJS.ProcessEnv} Process environment for nested git commands.
312
469
  */
313
- function gitEnv() {
470
+ function gitEnv(root) {
314
471
  const env = { ...process.env };
315
472
  delete env.GIT_DIR;
316
473
  delete env.GIT_WORK_TREE;
474
+ if (root) {
475
+ const gitDir = linkedWorktreeGitDir(root);
476
+ if (gitDir) {
477
+ env.GIT_DIR = gitDir;
478
+ env.GIT_WORK_TREE = root;
479
+ }
480
+ }
317
481
  return env;
318
482
  }
319
483
 
484
+ /**
485
+ * @param {string} root
486
+ * @returns {string | undefined}
487
+ */
488
+ function linkedWorktreeGitDir(root) {
489
+ const dotGit = path.join(root, ".git");
490
+ if (!existsSync(dotGit) || statSync(dotGit).isDirectory()) {
491
+ return undefined;
492
+ }
493
+ const match = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotGit, "utf8"));
494
+ if (!match) {
495
+ return undefined;
496
+ }
497
+ const gitDir = match[1].trim();
498
+ return path.isAbsolute(gitDir) ? gitDir : path.resolve(root, gitDir);
499
+ }
500
+
320
501
  if (import.meta.url === `file://${process.argv[1]}`) {
321
502
  try {
322
503
  const report = explainPluginSync(process.argv[2] ?? process.cwd());
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.106.3",
3
+ "version": "2.106.5",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.106.3",
3
+ "version": "2.106.5",
4
4
  "description": "AWS CDK-specific Lisa plugin.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.106.3",
3
+ "version": "2.106.5",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.106.3",
3
+ "version": "2.106.5",
4
4
  "description": "Expo and React Native-specific skills, agents, rules, and MCP servers.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.106.3",
3
+ "version": "2.106.5",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.106.3",
3
+ "version": "2.106.5",
4
4
  "description": "Harper/Fabric-specific Lisa rules for TypeScript component apps.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "2.106.3",
3
+ "version": "2.106.5",
4
4
  "description": "NestJS-specific skills (GraphQL, TypeORM) and hooks (migration write-protection)",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "2.106.3",
3
+ "version": "2.106.5",
4
4
  "description": "NestJS-specific skills and migration write-protection hooks.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-openclaw",
3
- "version": "2.106.3",
3
+ "version": "2.106.5",
4
4
  "description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-openclaw",
3
- "version": "2.106.3",
3
+ "version": "2.106.5",
4
4
  "description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, across Claude and Codex.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "2.106.3",
3
+ "version": "2.106.5",
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": "2.106.3",
3
+ "version": "2.106.5",
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-typescript",
3
- "version": "2.106.3",
3
+ "version": "2.106.5",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint 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": "2.106.3",
3
+ "version": "2.106.5",
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-wiki",
3
- "version": "2.106.3",
3
+ "version": "2.106.5",
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": "2.106.3",
3
+ "version": "2.106.5",
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"
@@ -7,7 +7,16 @@
7
7
  * diagnostic explains likely causes before an operator mutates the tree.
8
8
  */
9
9
  import { execFileSync } from "node:child_process";
10
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
10
+ import {
11
+ cpSync,
12
+ existsSync,
13
+ mkdtempSync,
14
+ readdirSync,
15
+ readFileSync,
16
+ rmSync,
17
+ statSync,
18
+ } from "node:fs";
19
+ import { tmpdir } from "node:os";
11
20
  import path from "node:path";
12
21
  import process from "node:process";
13
22
 
@@ -29,6 +38,7 @@ const GIT_BIN = "/usr/bin/git";
29
38
  * readonly classification: string
30
39
  * readonly path: string
31
40
  * readonly counterpart?: string
41
+ * readonly likelyCause: string
32
42
  * readonly nextAction: string
33
43
  * }} PluginSyncFinding
34
44
  *
@@ -50,8 +60,13 @@ export function explainPluginSync(root = process.cwd()) {
50
60
  const repoRoot = path.resolve(root);
51
61
  const statusBefore = gitStatus(repoRoot);
52
62
  const statusEntries = parseGitStatus(statusBefore);
63
+ const expectedGeneratedFiles = buildExpectedGeneratedFiles(repoRoot);
53
64
  const findings = [
54
- ...classifySourceGeneratedDrift(statusEntries),
65
+ ...classifySourceGeneratedDrift(
66
+ repoRoot,
67
+ statusEntries,
68
+ expectedGeneratedFiles
69
+ ),
55
70
  ...classifyMarketplaceDrift(repoRoot),
56
71
  ];
57
72
  const statusAfter = gitStatus(repoRoot);
@@ -97,6 +112,7 @@ export function renderPluginSyncReport(report) {
97
112
  lines.push(
98
113
  `- ${finding.classification}: ${finding.path}`,
99
114
  ` Evidence: ${finding.counterpart ? `${finding.path} -> ${finding.counterpart}` : finding.path}`,
115
+ ` Likely cause: ${finding.likelyCause}`,
100
116
  ` Next action: ${finding.nextAction}`
101
117
  );
102
118
  }
@@ -128,9 +144,10 @@ function highestClassification(findings) {
128
144
 
129
145
  /**
130
146
  * @param {readonly { readonly code: string, readonly file: string }[]} entries
147
+ * @param {ReadonlyMap<string, Buffer> | undefined} expectedGeneratedFiles
131
148
  * @returns {PluginSyncFinding[]}
132
149
  */
133
- function classifySourceGeneratedDrift(entries) {
150
+ function classifySourceGeneratedDrift(root, entries, expectedGeneratedFiles) {
134
151
  const changed = new Set(entries.map(entry => entry.file));
135
152
  const findings = [];
136
153
 
@@ -138,10 +155,24 @@ function classifySourceGeneratedDrift(entries) {
138
155
  const sourceCounterpart = sourceToBuilt(entry.file);
139
156
  if (sourceCounterpart) {
140
157
  const generatedChanged = changed.has(sourceCounterpart);
158
+ if (expectedGeneratedFiles) {
159
+ const expected = expectedGeneratedFiles.get(sourceCounterpart);
160
+ const active = readOptionalFile(root, sourceCounterpart);
161
+ const generatedMatchesSource =
162
+ expected !== undefined &&
163
+ active !== undefined &&
164
+ expected.equals(active);
165
+ if (generatedMatchesSource) {
166
+ continue;
167
+ }
168
+ }
141
169
  findings.push({
142
170
  classification: generatedChanged ? "OUT_OF_SYNC" : "SOURCE_NOT_BUILT",
143
171
  path: entry.file,
144
172
  counterpart: sourceCounterpart,
173
+ likelyCause: generatedChanged
174
+ ? "Both source and generated artifacts changed, but the generated artifact does not match a fresh plugin build."
175
+ : "A source plugin file changed, but its generated artifact was not rebuilt from plugins/src.",
145
176
  nextAction: generatedChanged
146
177
  ? "Review both source and generated changes, keep source authoritative, then run `bun run build:plugins && bun run check:plugins`."
147
178
  : "Run `bun run build:plugins`, then `bun run check:plugins`, and commit source plus regenerated artifacts.",
@@ -151,10 +182,23 @@ function classifySourceGeneratedDrift(entries) {
151
182
 
152
183
  const builtCounterpart = builtToSource(entry.file);
153
184
  if (builtCounterpart && !changed.has(builtCounterpart)) {
185
+ if (expectedGeneratedFiles) {
186
+ const expected = expectedGeneratedFiles.get(entry.file);
187
+ const active = readOptionalFile(root, entry.file);
188
+ if (
189
+ expected !== undefined &&
190
+ active !== undefined &&
191
+ expected.equals(active)
192
+ ) {
193
+ continue;
194
+ }
195
+ }
154
196
  findings.push({
155
197
  classification: "GENERATED_ONLY",
156
198
  path: entry.file,
157
199
  counterpart: builtCounterpart,
200
+ likelyCause:
201
+ "A generated plugin artifact changed without the matching plugins/src source change.",
158
202
  nextAction:
159
203
  "Move the edit to the matching plugins/src path, rebuild with `bun run build:plugins`, then run `bun run check:plugins`.",
160
204
  });
@@ -198,6 +242,8 @@ function classifyMarketplaceDrift(root) {
198
242
  classification: "MARKETPLACE_REGISTRATION_DRIFT",
199
243
  path: source.replace(/^\.\//, ""),
200
244
  counterpart: MARKETPLACE,
245
+ likelyCause:
246
+ "A built plugin directory exists but is not advertised by the marketplace manifest.",
201
247
  nextAction: `Add marketplace source "${source}" to \`${MARKETPLACE}\`, then run \`bun run check:plugins\`.`,
202
248
  });
203
249
  }
@@ -209,6 +255,8 @@ function classifyMarketplaceDrift(root) {
209
255
  classification: "MARKETPLACE_REGISTRATION_DRIFT",
210
256
  path: MARKETPLACE,
211
257
  counterpart: source.replace(/^\.\//, ""),
258
+ likelyCause:
259
+ "The marketplace manifest points at a built plugin directory that is missing.",
212
260
  nextAction:
213
261
  "Either restore the built plugin directory or remove the stale marketplace source, then run `bun run check:plugins`.",
214
262
  });
@@ -218,6 +266,113 @@ function classifyMarketplaceDrift(root) {
218
266
  return findings;
219
267
  }
220
268
 
269
+ /**
270
+ * Build plugins in a disposable copy and return the generated bytes that source
271
+ * changes should produce. If a minimal fixture cannot run the build script,
272
+ * callers fall back to status-based classification without mutating the repo.
273
+ * @param {string} root
274
+ * @returns {ReadonlyMap<string, Buffer> | undefined}
275
+ */
276
+ function buildExpectedGeneratedFiles(root) {
277
+ if (!existsSync(path.join(root, "scripts/build-plugins.sh"))) {
278
+ return undefined;
279
+ }
280
+
281
+ const tempRoot = mkdtempSync(path.join(tmpdir(), "lisa-plugin-sync-"));
282
+ const scratchRoot = path.join(tempRoot, "repo");
283
+ try {
284
+ cpSync(root, scratchRoot, {
285
+ recursive: true,
286
+ filter: src => shouldCopyToScratch(root, src),
287
+ });
288
+ execFileSync("bash", ["scripts/build-plugins.sh"], {
289
+ cwd: scratchRoot,
290
+ encoding: "utf8",
291
+ env: gitEnv(),
292
+ stdio: "ignore",
293
+ });
294
+ return collectGeneratedFiles(scratchRoot);
295
+ } catch {
296
+ return undefined;
297
+ } finally {
298
+ rmSync(tempRoot, { force: true, recursive: true });
299
+ }
300
+ }
301
+
302
+ /**
303
+ * @param {string} root
304
+ * @param {string} src
305
+ * @returns {boolean}
306
+ */
307
+ function shouldCopyToScratch(root, src) {
308
+ const rel = path.relative(root, src).split(path.sep).join("/");
309
+ if (rel === "") {
310
+ return true;
311
+ }
312
+ return !(
313
+ rel === ".git" ||
314
+ rel.startsWith(".git/") ||
315
+ rel === "node_modules" ||
316
+ rel.startsWith("node_modules/") ||
317
+ rel === "dist" ||
318
+ rel.startsWith("dist/") ||
319
+ rel === "coverage" ||
320
+ rel.startsWith("coverage/")
321
+ );
322
+ }
323
+
324
+ /**
325
+ * @param {string} root
326
+ * @returns {Map<string, Buffer>}
327
+ */
328
+ function collectGeneratedFiles(root) {
329
+ const files = new Map();
330
+ const pluginsRoot = path.join(root, PLUGINS_DIR);
331
+ if (!existsSync(pluginsRoot)) {
332
+ return files;
333
+ }
334
+ for (const name of readdirSync(pluginsRoot)) {
335
+ if (name === "src" || !name.startsWith("lisa")) {
336
+ continue;
337
+ }
338
+ collectFiles(root, path.join(pluginsRoot, name), files);
339
+ }
340
+ return files;
341
+ }
342
+
343
+ /**
344
+ * @param {string} root
345
+ * @param {string} current
346
+ * @param {Map<string, Buffer>} files
347
+ */
348
+ function collectFiles(root, current, files) {
349
+ const stats = statSync(current);
350
+ if (stats.isDirectory()) {
351
+ for (const child of readdirSync(current)) {
352
+ collectFiles(root, path.join(current, child), files);
353
+ }
354
+ return;
355
+ }
356
+ if (stats.isFile()) {
357
+ files.set(
358
+ path.relative(root, current).split(path.sep).join("/"),
359
+ readFileSync(current)
360
+ );
361
+ }
362
+ }
363
+
364
+ /**
365
+ * @param {string} root
366
+ * @param {string} rel
367
+ * @returns {Buffer | undefined}
368
+ */
369
+ function readOptionalFile(root, rel) {
370
+ const abs = path.join(root, rel);
371
+ return existsSync(abs) && statSync(abs).isFile()
372
+ ? readFileSync(abs)
373
+ : undefined;
374
+ }
375
+
221
376
  /**
222
377
  * @param {string} root
223
378
  * @returns {string}
@@ -229,7 +384,7 @@ function gitStatus(root) {
229
384
  {
230
385
  cwd: root,
231
386
  encoding: "utf8",
232
- env: gitEnv(),
387
+ env: gitEnv(root),
233
388
  }
234
389
  );
235
390
  }
@@ -308,15 +463,41 @@ function dedupeFindings(findings) {
308
463
 
309
464
  /**
310
465
  * Remove parent-hook Git environment so diagnostics inspect the requested repo.
466
+ * Bare common-dir worktrees in Codex need explicit GIT_DIR/GIT_WORK_TREE.
467
+ * @param {string} [root] Repository path the nested git command should inspect.
311
468
  * @returns {NodeJS.ProcessEnv} Process environment for nested git commands.
312
469
  */
313
- function gitEnv() {
470
+ function gitEnv(root) {
314
471
  const env = { ...process.env };
315
472
  delete env.GIT_DIR;
316
473
  delete env.GIT_WORK_TREE;
474
+ if (root) {
475
+ const gitDir = linkedWorktreeGitDir(root);
476
+ if (gitDir) {
477
+ env.GIT_DIR = gitDir;
478
+ env.GIT_WORK_TREE = root;
479
+ }
480
+ }
317
481
  return env;
318
482
  }
319
483
 
484
+ /**
485
+ * @param {string} root
486
+ * @returns {string | undefined}
487
+ */
488
+ function linkedWorktreeGitDir(root) {
489
+ const dotGit = path.join(root, ".git");
490
+ if (!existsSync(dotGit) || statSync(dotGit).isDirectory()) {
491
+ return undefined;
492
+ }
493
+ const match = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotGit, "utf8"));
494
+ if (!match) {
495
+ return undefined;
496
+ }
497
+ const gitDir = match[1].trim();
498
+ return path.isAbsolute(gitDir) ? gitDir : path.resolve(root, gitDir);
499
+ }
500
+
320
501
  if (import.meta.url === `file://${process.argv[1]}`) {
321
502
  try {
322
503
  const report = explainPluginSync(process.argv[2] ?? process.cwd());