@codyswann/lisa 2.106.2 → 2.106.4

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.2",
85
+ "version": "2.106.4",
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.2",
3
+ "version": "2.106.4",
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.2",
3
+ "version": "2.106.4",
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,8 +242,9 @@ function classifyMarketplaceDrift(root) {
198
242
  classification: "MARKETPLACE_REGISTRATION_DRIFT",
199
243
  path: source.replace(/^\.\//, ""),
200
244
  counterpart: MARKETPLACE,
201
- nextAction:
202
- "Add the built plugin source to `.claude-plugin/marketplace.json`, then run `bun run check:plugins`.",
245
+ likelyCause:
246
+ "A built plugin directory exists but is not advertised by the marketplace manifest.",
247
+ nextAction: `Add marketplace source "${source}" to \`${MARKETPLACE}\`, then run \`bun run check:plugins\`.`,
203
248
  });
204
249
  }
205
250
  }
@@ -210,6 +255,8 @@ function classifyMarketplaceDrift(root) {
210
255
  classification: "MARKETPLACE_REGISTRATION_DRIFT",
211
256
  path: MARKETPLACE,
212
257
  counterpart: source.replace(/^\.\//, ""),
258
+ likelyCause:
259
+ "The marketplace manifest points at a built plugin directory that is missing.",
213
260
  nextAction:
214
261
  "Either restore the built plugin directory or remove the stale marketplace source, then run `bun run check:plugins`.",
215
262
  });
@@ -219,6 +266,113 @@ function classifyMarketplaceDrift(root) {
219
266
  return findings;
220
267
  }
221
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
+
222
376
  /**
223
377
  * @param {string} root
224
378
  * @returns {string}
@@ -230,7 +384,7 @@ function gitStatus(root) {
230
384
  {
231
385
  cwd: root,
232
386
  encoding: "utf8",
233
- env: gitEnv(),
387
+ env: gitEnv(root),
234
388
  }
235
389
  );
236
390
  }
@@ -309,15 +463,41 @@ function dedupeFindings(findings) {
309
463
 
310
464
  /**
311
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.
312
468
  * @returns {NodeJS.ProcessEnv} Process environment for nested git commands.
313
469
  */
314
- function gitEnv() {
470
+ function gitEnv(root) {
315
471
  const env = { ...process.env };
316
472
  delete env.GIT_DIR;
317
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
+ }
318
481
  return env;
319
482
  }
320
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
+
321
501
  if (import.meta.url === `file://${process.argv[1]}`) {
322
502
  try {
323
503
  const report = explainPluginSync(process.argv[2] ?? process.cwd());
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.106.2",
3
+ "version": "2.106.4",
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.2",
3
+ "version": "2.106.4",
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.2",
3
+ "version": "2.106.4",
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.2",
3
+ "version": "2.106.4",
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.2",
3
+ "version": "2.106.4",
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.2",
3
+ "version": "2.106.4",
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.2",
3
+ "version": "2.106.4",
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.2",
3
+ "version": "2.106.4",
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.2",
3
+ "version": "2.106.4",
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.2",
3
+ "version": "2.106.4",
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.2",
3
+ "version": "2.106.4",
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.2",
3
+ "version": "2.106.4",
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.2",
3
+ "version": "2.106.4",
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.2",
3
+ "version": "2.106.4",
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.2",
3
+ "version": "2.106.4",
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.2",
3
+ "version": "2.106.4",
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,8 +242,9 @@ function classifyMarketplaceDrift(root) {
198
242
  classification: "MARKETPLACE_REGISTRATION_DRIFT",
199
243
  path: source.replace(/^\.\//, ""),
200
244
  counterpart: MARKETPLACE,
201
- nextAction:
202
- "Add the built plugin source to `.claude-plugin/marketplace.json`, then run `bun run check:plugins`.",
245
+ likelyCause:
246
+ "A built plugin directory exists but is not advertised by the marketplace manifest.",
247
+ nextAction: `Add marketplace source "${source}" to \`${MARKETPLACE}\`, then run \`bun run check:plugins\`.`,
203
248
  });
204
249
  }
205
250
  }
@@ -210,6 +255,8 @@ function classifyMarketplaceDrift(root) {
210
255
  classification: "MARKETPLACE_REGISTRATION_DRIFT",
211
256
  path: MARKETPLACE,
212
257
  counterpart: source.replace(/^\.\//, ""),
258
+ likelyCause:
259
+ "The marketplace manifest points at a built plugin directory that is missing.",
213
260
  nextAction:
214
261
  "Either restore the built plugin directory or remove the stale marketplace source, then run `bun run check:plugins`.",
215
262
  });
@@ -219,6 +266,113 @@ function classifyMarketplaceDrift(root) {
219
266
  return findings;
220
267
  }
221
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
+
222
376
  /**
223
377
  * @param {string} root
224
378
  * @returns {string}
@@ -230,7 +384,7 @@ function gitStatus(root) {
230
384
  {
231
385
  cwd: root,
232
386
  encoding: "utf8",
233
- env: gitEnv(),
387
+ env: gitEnv(root),
234
388
  }
235
389
  );
236
390
  }
@@ -309,15 +463,41 @@ function dedupeFindings(findings) {
309
463
 
310
464
  /**
311
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.
312
468
  * @returns {NodeJS.ProcessEnv} Process environment for nested git commands.
313
469
  */
314
- function gitEnv() {
470
+ function gitEnv(root) {
315
471
  const env = { ...process.env };
316
472
  delete env.GIT_DIR;
317
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
+ }
318
481
  return env;
319
482
  }
320
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
+
321
501
  if (import.meta.url === `file://${process.argv[1]}`) {
322
502
  try {
323
503
  const report = explainPluginSync(process.argv[2] ?? process.cwd());