@codyswann/lisa 2.189.0 → 2.189.2
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 +3 -2
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/scripts/detect-stale-workflow-inputs.mjs +455 -0
- package/scripts/install-claude-plugins.sh +75 -17
package/package.json
CHANGED
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
"lisa:commit-and-pr:local": "bash scripts/lisa-commit-and-pr-local.sh",
|
|
32
32
|
"prepublishOnly": "$npm_execpath run build",
|
|
33
33
|
"check:plugins": "bash scripts/check-plugins-sync.sh",
|
|
34
|
-
"check:rules-pairing": "bash scripts/check-rules-pairing.sh"
|
|
34
|
+
"check:rules-pairing": "bash scripts/check-rules-pairing.sh",
|
|
35
|
+
"check:workflow-inputs": "node scripts/detect-stale-workflow-inputs.mjs --project . --project typescript/create-only --project rails/create-only --json"
|
|
35
36
|
},
|
|
36
37
|
"engines": {
|
|
37
38
|
"npm": "please-use-bun",
|
|
@@ -93,7 +94,7 @@
|
|
|
93
94
|
"ws": ">=8.20.1"
|
|
94
95
|
},
|
|
95
96
|
"name": "@codyswann/lisa",
|
|
96
|
-
"version": "2.189.
|
|
97
|
+
"version": "2.189.2",
|
|
97
98
|
"description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
|
|
98
99
|
"main": "dist/index.js",
|
|
99
100
|
"exports": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lisa-openclaw",
|
|
3
|
-
"version": "2.189.
|
|
3
|
+
"version": "2.189.2",
|
|
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.189.
|
|
3
|
+
"version": "2.189.2",
|
|
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-openclaw",
|
|
3
|
-
"version": "2.189.
|
|
3
|
+
"version": "2.189.2",
|
|
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.189.
|
|
3
|
+
"version": "2.189.2",
|
|
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.189.
|
|
3
|
+
"version": "2.189.2",
|
|
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"
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic detector for stale reusable-workflow inputs (issue #1423).
|
|
4
|
+
*
|
|
5
|
+
* Context: a caller workflow (e.g. a consumer repo's `.github/workflows/claude.yml`)
|
|
6
|
+
* invokes one of Lisa's reusable workflows via `uses: <owner>/<repo>/.github/workflows/
|
|
7
|
+
* reusable-*.yml@<ref>` and passes a `with:` block. GitHub hard-fails the run at
|
|
8
|
+
* startup if that `with:` block names an input the reusable workflow's
|
|
9
|
+
* `workflow_call.inputs` no longer declares (e.g. an early-generation caller still
|
|
10
|
+
* passing `package_manager`, which `reusable-claude.yml` never declared). Because
|
|
11
|
+
* `claude.yml` is a create-only file Lisa never overwrites, an affected repo can
|
|
12
|
+
* never self-heal — the drift has to be found and reported (or migrated) instead.
|
|
13
|
+
*
|
|
14
|
+
* This script scans every workflow file directly under a project's
|
|
15
|
+
* `.github/workflows/` for such callers, resolves each referenced reusable
|
|
16
|
+
* workflow's declared inputs from a contracts root (defaults to this repo's own
|
|
17
|
+
* `.github/workflows/`, the source of truth for the current contract), and reports
|
|
18
|
+
* any caller `with:` key absent from the declared set as `stale`.
|
|
19
|
+
*
|
|
20
|
+
* It is intentionally NOT a general YAML parser — like `migrate-deploy-order.sh`,
|
|
21
|
+
* it does targeted, indentation-aware line scanning of the two shapes it cares
|
|
22
|
+
* about (`uses:` + sibling `with:`, and `workflow_call: inputs:`). That is
|
|
23
|
+
* sufficient for Lisa-authored workflow files, which have a stable, predictable
|
|
24
|
+
* structure, and keeps the detector dependency-free (Node built-ins only, no
|
|
25
|
+
* network access — mirrors `scripts/plugin-parity-drift.mjs`'s determinism goals).
|
|
26
|
+
*
|
|
27
|
+
* It never edits a caller file — it only reports, so `lisa-update-projects` (or
|
|
28
|
+
* CI) can decide SAFE/REVIEW/migrate from the result.
|
|
29
|
+
*
|
|
30
|
+
* CLI:
|
|
31
|
+
* node scripts/detect-stale-workflow-inputs.mjs [--project <dir>]... [--contracts-root <dir>] [--json]
|
|
32
|
+
*
|
|
33
|
+
* Exit codes:
|
|
34
|
+
* 0 — no stale inputs found (includes the case of zero callers scanned).
|
|
35
|
+
* 1 — stale input(s) found in at least one caller.
|
|
36
|
+
* 2 — operational/usage error: unknown flag, a flag missing its value, a
|
|
37
|
+
* `--project` that isn't a directory, or a filesystem error while scanning.
|
|
38
|
+
*
|
|
39
|
+
* @module scripts/detect-stale-workflow-inputs
|
|
40
|
+
*/
|
|
41
|
+
import fs from "node:fs";
|
|
42
|
+
import path from "node:path";
|
|
43
|
+
import process from "node:process";
|
|
44
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
45
|
+
|
|
46
|
+
const REPO_ROOT = path.resolve(
|
|
47
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
48
|
+
".."
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Usage error — thrown by `parseArgs` for an invalid invocation so `main` can
|
|
53
|
+
* distinguish it (exit 2) from a drift result (exit 1).
|
|
54
|
+
*/
|
|
55
|
+
export class UsageError extends Error {}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* True iff `target` is an existing directory.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} target - filesystem path.
|
|
61
|
+
* @returns {boolean} whether `target` resolves to a directory.
|
|
62
|
+
*/
|
|
63
|
+
function isDirectory(target) {
|
|
64
|
+
try {
|
|
65
|
+
return fs.statSync(target).isDirectory();
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Leading-whitespace width of a line (its indentation level).
|
|
73
|
+
*
|
|
74
|
+
* @param {string} line - a single source line.
|
|
75
|
+
* @returns {number} number of leading whitespace characters.
|
|
76
|
+
*/
|
|
77
|
+
function indentOf(line) {
|
|
78
|
+
return line.length - line.trimStart().length;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Collect the immediate child `key:` names nested directly under a block
|
|
83
|
+
* opener line, given the opener's own indentation. Stops at the first line
|
|
84
|
+
* whose indentation is `<= parentIndent` (blank lines are skipped).
|
|
85
|
+
*
|
|
86
|
+
* @param {readonly string[]} lines - full file, split into lines.
|
|
87
|
+
* @param {number} openerIndex - index of the block-opener line (e.g. `with:`).
|
|
88
|
+
* @param {number} parentIndent - indentation width of the opener line itself.
|
|
89
|
+
* @returns {string[]} the immediate child key names, in file order.
|
|
90
|
+
*/
|
|
91
|
+
function collectImmediateChildKeys(lines, openerIndex, parentIndent) {
|
|
92
|
+
const keys = [];
|
|
93
|
+
for (let k = openerIndex + 1; k < lines.length; k++) {
|
|
94
|
+
const line = lines[k];
|
|
95
|
+
if (line.trim() === "") {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const lineIndent = indentOf(line);
|
|
99
|
+
if (lineIndent <= parentIndent) {
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
const match = /^\s*([A-Za-z0-9_-]+):/.exec(line);
|
|
103
|
+
if (match && lineIndent === parentIndent + 2) {
|
|
104
|
+
keys.push(match[1]);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return keys;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Parse a `uses:` value referencing a reusable workflow into its basename and
|
|
112
|
+
* ref, or `null` if it doesn't match the `.../.github/workflows/<file>@<ref>`
|
|
113
|
+
* shape (e.g. a plain action reference like `actions/checkout@v6`).
|
|
114
|
+
*
|
|
115
|
+
* @param {string} usesValue - the raw value after `uses:`.
|
|
116
|
+
* @returns {{ file: string, ref: string } | null} the parsed reference.
|
|
117
|
+
*/
|
|
118
|
+
export function parseReusableReference(usesValue) {
|
|
119
|
+
const match = /\.github\/workflows\/([\w.-]+\.ya?ml)@(\S+)$/.exec(
|
|
120
|
+
usesValue.trim()
|
|
121
|
+
);
|
|
122
|
+
if (!match) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
return { file: match[1], ref: match[2] };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Scan a caller workflow file's contents for every job that invokes a
|
|
130
|
+
* reusable workflow, returning the `with:` keys each job passes.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} content - full caller workflow file contents.
|
|
133
|
+
* @returns {{ reusableFile: string, ref: string, withKeys: string[] }[]}
|
|
134
|
+
* one entry per job that calls a reusable workflow (in file order).
|
|
135
|
+
*/
|
|
136
|
+
export function extractCallerJobs(content) {
|
|
137
|
+
const lines = content.split(/\r?\n/);
|
|
138
|
+
const jobs = [];
|
|
139
|
+
for (let i = 0; i < lines.length; i++) {
|
|
140
|
+
const usesMatch = /^(\s*)uses:\s*(\S+)\s*$/.exec(lines[i]);
|
|
141
|
+
if (!usesMatch) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const [, indentStr, usesValue] = usesMatch;
|
|
145
|
+
const ref = parseReusableReference(usesValue);
|
|
146
|
+
if (!ref) {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const indent = indentStr.length;
|
|
150
|
+
let withKeys = [];
|
|
151
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
152
|
+
const line = lines[j];
|
|
153
|
+
if (line.trim() === "") {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const lineIndent = indentOf(line);
|
|
157
|
+
if (lineIndent < indent) {
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
if (lineIndent === indent && /^\s*with:\s*$/.test(line)) {
|
|
161
|
+
withKeys = collectImmediateChildKeys(lines, j, indent).sort();
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
jobs.push({ reusableFile: ref.file, ref: ref.ref, withKeys });
|
|
166
|
+
}
|
|
167
|
+
return jobs;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Extract the declared `on.workflow_call.inputs` key names from a reusable
|
|
172
|
+
* workflow's contents.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} content - full reusable workflow file contents.
|
|
175
|
+
* @returns {string[] | null} declared input names (sorted), an empty array when
|
|
176
|
+
* the reusable workflow declares no inputs, or `null` if the file has no
|
|
177
|
+
* `workflow_call:` block.
|
|
178
|
+
*/
|
|
179
|
+
export function extractDeclaredInputs(content) {
|
|
180
|
+
const lines = content.split(/\r?\n/);
|
|
181
|
+
for (let i = 0; i < lines.length; i++) {
|
|
182
|
+
if (!/^\s*workflow_call:\s*$/.test(lines[i])) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const wcIndent = indentOf(lines[i]);
|
|
186
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
187
|
+
const line = lines[j];
|
|
188
|
+
if (line.trim() === "") {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const lineIndent = indentOf(line);
|
|
192
|
+
if (lineIndent <= wcIndent) {
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
if (lineIndent === wcIndent + 2 && /^\s*inputs:\s*$/.test(line)) {
|
|
196
|
+
return collectImmediateChildKeys(lines, j, lineIndent).sort();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Keys present in `used` but absent from `declared` — i.e. inputs a caller
|
|
206
|
+
* passes that the reusable workflow's contract no longer accepts.
|
|
207
|
+
*
|
|
208
|
+
* @param {readonly string[]} used - `with:` keys a caller passes.
|
|
209
|
+
* @param {readonly string[]} declared - `workflow_call.inputs` keys the
|
|
210
|
+
* reusable workflow declares.
|
|
211
|
+
* @returns {string[]} the stale keys, sorted.
|
|
212
|
+
*/
|
|
213
|
+
export function diffStaleKeys(used, declared) {
|
|
214
|
+
const declaredSet = new Set(declared);
|
|
215
|
+
return used.filter(key => !declaredSet.has(key)).sort();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Resolve a reusable workflow's declared inputs from the contracts root.
|
|
220
|
+
*
|
|
221
|
+
* @param {string} contractsRoot - directory holding the current reusable
|
|
222
|
+
* workflow files (basename-addressed).
|
|
223
|
+
* @param {string} reusableFile - basename of the referenced reusable
|
|
224
|
+
* workflow file (e.g. `reusable-claude.yml`).
|
|
225
|
+
* @returns {string[] | null} declared inputs, or `null` if the contract file
|
|
226
|
+
* isn't present under `contractsRoot` (unresolvable, not necessarily stale).
|
|
227
|
+
*/
|
|
228
|
+
function resolveContractInputs(contractsRoot, reusableFile) {
|
|
229
|
+
const contractPath = path.join(contractsRoot, reusableFile);
|
|
230
|
+
if (!fs.existsSync(contractPath)) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
return extractDeclaredInputs(fs.readFileSync(contractPath, "utf8"));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Scan one caller workflow file and classify each of its reusable-workflow
|
|
238
|
+
* jobs against the contracts root.
|
|
239
|
+
*
|
|
240
|
+
* @param {string} filePath - absolute path to the caller workflow file.
|
|
241
|
+
* @param {string} contractsRoot - directory holding current reusable workflows.
|
|
242
|
+
* @returns {{ reusableFile: string, ref: string, withKeys: string[], staleInputs: string[], status: "ok" | "stale" | "unknown-contract" }[]}
|
|
243
|
+
* one row per reusable-workflow job found in the file.
|
|
244
|
+
*/
|
|
245
|
+
function scanCallerFile(filePath, contractsRoot) {
|
|
246
|
+
const jobs = extractCallerJobs(fs.readFileSync(filePath, "utf8"));
|
|
247
|
+
return jobs.map(job => {
|
|
248
|
+
const declared = resolveContractInputs(contractsRoot, job.reusableFile);
|
|
249
|
+
if (declared === null) {
|
|
250
|
+
return { ...job, staleInputs: [], status: "unknown-contract" };
|
|
251
|
+
}
|
|
252
|
+
const staleInputs = diffStaleKeys(job.withKeys, declared);
|
|
253
|
+
return {
|
|
254
|
+
...job,
|
|
255
|
+
staleInputs,
|
|
256
|
+
status: staleInputs.length > 0 ? "stale" : "ok",
|
|
257
|
+
};
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* List every `.yml`/`.yaml` file directly under `<root>/.github/workflows`
|
|
263
|
+
* (non-recursive — GitHub Actions never nests workflow files deeper).
|
|
264
|
+
*
|
|
265
|
+
* @param {string} root - a project root directory.
|
|
266
|
+
* @returns {string[]} absolute file paths, sorted.
|
|
267
|
+
*/
|
|
268
|
+
function collectWorkflowFiles(root) {
|
|
269
|
+
const dir = path.join(root, ".github", "workflows");
|
|
270
|
+
if (!isDirectory(dir)) {
|
|
271
|
+
return [];
|
|
272
|
+
}
|
|
273
|
+
return fs
|
|
274
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
275
|
+
.filter(entry => entry.isFile() && /\.ya?ml$/.test(entry.name))
|
|
276
|
+
.map(entry => path.join(dir, entry.name))
|
|
277
|
+
.sort((a, b) => a.localeCompare(b));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Render a filesystem path relative to `root` using POSIX separators.
|
|
282
|
+
*
|
|
283
|
+
* @param {string} absPath - an absolute path.
|
|
284
|
+
* @param {string} root - the root to relativize against.
|
|
285
|
+
* @returns {string} a stable, display-friendly relative path.
|
|
286
|
+
*/
|
|
287
|
+
function displayPath(absPath, root) {
|
|
288
|
+
return path.relative(root, absPath).split(path.sep).join("/");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Assemble the machine-readable report.
|
|
293
|
+
*
|
|
294
|
+
* @param {ReadonlyArray<Record<string, unknown>>} results - per-job results.
|
|
295
|
+
* @param {{ projects: readonly string[], contractsRoot: string }} opts - options.
|
|
296
|
+
* @returns {Record<string, unknown>} the report object.
|
|
297
|
+
*/
|
|
298
|
+
export function buildReport(results, opts) {
|
|
299
|
+
const stale = results.filter(r => r.status === "stale").length;
|
|
300
|
+
const unknownContract = results.filter(
|
|
301
|
+
r => r.status === "unknown-contract"
|
|
302
|
+
).length;
|
|
303
|
+
return {
|
|
304
|
+
contractsRoot: opts.contractsRoot,
|
|
305
|
+
projects: opts.projects,
|
|
306
|
+
results,
|
|
307
|
+
schemaVersion: 1,
|
|
308
|
+
summary: {
|
|
309
|
+
ok: results.length - stale - unknownContract,
|
|
310
|
+
scanned: results.length,
|
|
311
|
+
stale,
|
|
312
|
+
unknownContract,
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Sanitize a value for a markdown-table cell.
|
|
319
|
+
*
|
|
320
|
+
* @param {string | readonly string[] | null | undefined} value - raw cell value.
|
|
321
|
+
* @returns {string} a table-safe string (`-` for null/undefined/empty array).
|
|
322
|
+
*/
|
|
323
|
+
function cell(value) {
|
|
324
|
+
if (value === null || value === undefined) {
|
|
325
|
+
return "-";
|
|
326
|
+
}
|
|
327
|
+
const text = Array.isArray(value) ? value.join(", ") : String(value);
|
|
328
|
+
return text === "" ? "-" : text.replace(/\r?\n/g, " ").replace(/\|/g, "\\|");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Render the human-readable markdown table + summary line.
|
|
333
|
+
*
|
|
334
|
+
* @param {{ results: ReadonlyArray<Record<string, unknown>>, summary: { scanned: number, stale: number, unknownContract: number } }} report
|
|
335
|
+
* the report object.
|
|
336
|
+
* @returns {string} the rendered table.
|
|
337
|
+
*/
|
|
338
|
+
function humanTable(report) {
|
|
339
|
+
const header =
|
|
340
|
+
"| project | file | reusable | stale inputs | status |\n" +
|
|
341
|
+
"| --- | --- | --- | --- | --- |";
|
|
342
|
+
const rows = report.results.map(
|
|
343
|
+
r =>
|
|
344
|
+
`| ${cell(r.project)} | ${cell(r.file)} | ${cell(r.reusableFile)} | ${cell(r.staleInputs)} | ${cell(r.status)} |`
|
|
345
|
+
);
|
|
346
|
+
const summary = `\n${report.summary.stale} of ${report.summary.scanned} caller jobs have stale inputs (${report.summary.unknownContract} unresolvable contract(s))`;
|
|
347
|
+
return [header, ...rows].join("\n") + summary;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Render a report to the chosen stream.
|
|
352
|
+
*
|
|
353
|
+
* @param {{ write(s: string): void }} out - the output stream.
|
|
354
|
+
* @param {Record<string, unknown>} report - the report object.
|
|
355
|
+
* @param {boolean} json - whether to emit JSON instead of the human table.
|
|
356
|
+
* @returns {void}
|
|
357
|
+
*/
|
|
358
|
+
function emitReport(out, report, json) {
|
|
359
|
+
out.write(
|
|
360
|
+
(json ? JSON.stringify(report, null, 2) : humanTable(report)) + "\n"
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Parse argv into resolved options. Throws `UsageError` on a bad invocation.
|
|
366
|
+
*
|
|
367
|
+
* @param {readonly string[]} argv - arguments (without node/script prefix).
|
|
368
|
+
* @returns {{ projects: string[], contractsRoot: string, json: boolean }} options.
|
|
369
|
+
*/
|
|
370
|
+
export function parseArgs(argv) {
|
|
371
|
+
const projects = [];
|
|
372
|
+
let contractsRoot = null;
|
|
373
|
+
let json = false;
|
|
374
|
+
for (let i = 0; i < argv.length; i++) {
|
|
375
|
+
const arg = argv[i];
|
|
376
|
+
if (arg === "--json") {
|
|
377
|
+
json = true;
|
|
378
|
+
} else if (arg === "--project") {
|
|
379
|
+
const next = argv[i + 1];
|
|
380
|
+
if (next === undefined || next.startsWith("--")) {
|
|
381
|
+
throw new UsageError("--project requires a value");
|
|
382
|
+
}
|
|
383
|
+
projects.push(next);
|
|
384
|
+
i += 1;
|
|
385
|
+
} else if (arg === "--contracts-root") {
|
|
386
|
+
const next = argv[i + 1];
|
|
387
|
+
if (next === undefined || next.startsWith("--")) {
|
|
388
|
+
throw new UsageError("--contracts-root requires a value");
|
|
389
|
+
}
|
|
390
|
+
contractsRoot = next;
|
|
391
|
+
i += 1;
|
|
392
|
+
} else {
|
|
393
|
+
throw new UsageError(`unknown argument: ${arg}`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const resolvedProjects = projects.length > 0 ? projects : [process.cwd()];
|
|
397
|
+
return {
|
|
398
|
+
contractsRoot: path.resolve(
|
|
399
|
+
contractsRoot ?? path.join(REPO_ROOT, ".github", "workflows")
|
|
400
|
+
),
|
|
401
|
+
json,
|
|
402
|
+
projects: resolvedProjects.map(p => path.resolve(p)),
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Run the detector. Returns the process exit code (does not call `exit`).
|
|
408
|
+
*
|
|
409
|
+
* @param {readonly string[]} argv - arguments (without node/script prefix).
|
|
410
|
+
* @param {{ stdout?: { write(s: string): void }, stderr?: { write(s: string): void } }} [io]
|
|
411
|
+
* injectable streams (defaults to process streams).
|
|
412
|
+
* @returns {number} the exit code (0 ok, 1 stale found, 2 usage error).
|
|
413
|
+
*/
|
|
414
|
+
export function main(argv, io = {}) {
|
|
415
|
+
const out = io.stdout ?? process.stdout;
|
|
416
|
+
const err = io.stderr ?? process.stderr;
|
|
417
|
+
let opts;
|
|
418
|
+
try {
|
|
419
|
+
opts = parseArgs(argv);
|
|
420
|
+
} catch (error) {
|
|
421
|
+
err.write(`error: ${error.message}\n`);
|
|
422
|
+
return 2;
|
|
423
|
+
}
|
|
424
|
+
const missing = opts.projects.filter(p => !isDirectory(p));
|
|
425
|
+
if (missing.length > 0) {
|
|
426
|
+
err.write(`error: --project is not a directory: ${missing.join(", ")}\n`);
|
|
427
|
+
return 2;
|
|
428
|
+
}
|
|
429
|
+
let results;
|
|
430
|
+
try {
|
|
431
|
+
results = opts.projects.flatMap(project => {
|
|
432
|
+
const files = collectWorkflowFiles(project);
|
|
433
|
+
return files.flatMap(file =>
|
|
434
|
+
scanCallerFile(file, opts.contractsRoot).map(job => ({
|
|
435
|
+
file: displayPath(file, project),
|
|
436
|
+
project,
|
|
437
|
+
...job,
|
|
438
|
+
}))
|
|
439
|
+
);
|
|
440
|
+
});
|
|
441
|
+
} catch (error) {
|
|
442
|
+
err.write(`error: failed to scan workflow files: ${error.message}\n`);
|
|
443
|
+
return 2;
|
|
444
|
+
}
|
|
445
|
+
const report = buildReport(results, opts);
|
|
446
|
+
emitReport(out, report, opts.json);
|
|
447
|
+
return results.some(r => r.status === "stale") ? 1 : 0;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (
|
|
451
|
+
process.argv[1] &&
|
|
452
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
453
|
+
) {
|
|
454
|
+
process.exit(main(process.argv.slice(2)));
|
|
455
|
+
}
|
|
@@ -6,16 +6,79 @@ set -euo pipefail
|
|
|
6
6
|
PROJECT_ROOT="${npm_config_local_prefix:-${INIT_CWD:-}}"
|
|
7
7
|
if [ -z "$PROJECT_ROOT" ]; then exit 0; fi
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
SETTINGS_FILE="$PROJECT_ROOT/.claude/settings.json"
|
|
10
|
+
|
|
11
|
+
harness_includes_codex() {
|
|
12
|
+
[ "${IS_LISA_SELF:-false}" = "true" ] && return 0
|
|
13
|
+
node -e '
|
|
14
|
+
const fs = require("fs");
|
|
15
|
+
const path = process.argv[1];
|
|
16
|
+
try {
|
|
17
|
+
const parsed = JSON.parse(fs.readFileSync(path, "utf8"));
|
|
18
|
+
const harness = parsed.harness === "all" ? "fleet" : parsed.harness;
|
|
19
|
+
process.exit(harness === "codex" || harness === "fleet" ? 0 : 1);
|
|
20
|
+
} catch {
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
' "$PROJECT_ROOT/.lisa.config.json" >/dev/null 2>&1
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
detect_lisa_stack() {
|
|
27
|
+
local settings_file="$1"
|
|
28
|
+
[ -f "$settings_file" ] || return 0
|
|
29
|
+
command -v jq >/dev/null 2>&1 || return 0
|
|
30
|
+
local stack
|
|
31
|
+
for stack in expo nestjs cdk harper-fabric rails; do
|
|
32
|
+
if jq -e "(.enabledPlugins // {}) | has(\"lisa-${stack}@lisa\")" "$settings_file" >/dev/null 2>&1; then
|
|
33
|
+
printf '%s\n' "$stack"
|
|
34
|
+
return 0
|
|
35
|
+
fi
|
|
36
|
+
done
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
install_codex_lisa_plugins() {
|
|
40
|
+
harness_includes_codex || return 0
|
|
41
|
+
command -v codex >/dev/null 2>&1 || return 0
|
|
42
|
+
|
|
43
|
+
# Codex reads the Lisa marketplace from the project/repo root. For the Lisa
|
|
44
|
+
# repo itself this points at the working tree, not node_modules, so postinstall
|
|
45
|
+
# never needs to run the full template/deletion engine just to expose skills.
|
|
46
|
+
(cd "$PROJECT_ROOT" && codex plugin marketplace add "$PROJECT_ROOT" </dev/null >/dev/null 2>&1) || true
|
|
47
|
+
(cd "$PROJECT_ROOT" && codex plugin add "lisa@lisa" </dev/null >/dev/null 2>&1) || true
|
|
48
|
+
|
|
49
|
+
local lisa_stack
|
|
50
|
+
lisa_stack="$(detect_lisa_stack "$SETTINGS_FILE")"
|
|
51
|
+
case "$lisa_stack" in
|
|
52
|
+
rails) ;;
|
|
53
|
+
*)
|
|
54
|
+
(cd "$PROJECT_ROOT" && codex plugin add "lisa-typescript@lisa" </dev/null >/dev/null 2>&1) || true
|
|
55
|
+
;;
|
|
56
|
+
esac
|
|
57
|
+
|
|
58
|
+
if [ -n "$lisa_stack" ]; then
|
|
59
|
+
(cd "$PROJECT_ROOT" && codex plugin add "lisa-${lisa_stack}@lisa" </dev/null >/dev/null 2>&1) || true
|
|
60
|
+
fi
|
|
61
|
+
}
|
|
11
62
|
|
|
12
63
|
# Skip running Lisa on itself — the Lisa repo IS the template source.
|
|
13
|
-
# Self-running causes chicken-and-egg issues (npm package
|
|
64
|
+
# Self-running the full apply causes chicken-and-egg issues (npm package
|
|
65
|
+
# deletes source files). The self path below only registers plugin surfaces.
|
|
14
66
|
PACKAGE_NAME=$(node -e "console.log(require('$PROJECT_ROOT/package.json').name || '')" 2>/dev/null || true)
|
|
15
|
-
|
|
67
|
+
IS_LISA_SELF=false
|
|
68
|
+
if [ "$PACKAGE_NAME" = "@codyswann/lisa" ]; then
|
|
69
|
+
IS_LISA_SELF=true
|
|
70
|
+
LISA_DIR="$PROJECT_ROOT"
|
|
71
|
+
else
|
|
72
|
+
LISA_DIR="$PROJECT_ROOT/node_modules/@codyswann/lisa"
|
|
73
|
+
if [ ! -d "$LISA_DIR" ]; then exit 0; fi
|
|
74
|
+
fi
|
|
16
75
|
|
|
17
76
|
cd "$PROJECT_ROOT"
|
|
18
77
|
|
|
78
|
+
if [ "$IS_LISA_SELF" = "true" ]; then
|
|
79
|
+
install_codex_lisa_plugins
|
|
80
|
+
fi
|
|
81
|
+
|
|
19
82
|
# Apply Lisa templates non-interactively (init when missing, update when present),
|
|
20
83
|
# EXCEPT in CI. --skip-git-check bypasses the dirty working directory check since
|
|
21
84
|
# package.json and the lockfile are always uncommitted during postinstall.
|
|
@@ -34,18 +97,21 @@ cd "$PROJECT_ROOT"
|
|
|
34
97
|
# each path is written exactly once by its most-specific stack — there is no
|
|
35
98
|
# intermediate clobbered state to be interrupted in. See src/core/lisa.ts
|
|
36
99
|
# loadCopyOverwriteOwnership.
|
|
37
|
-
if [ -z "${CI:-}" ]; then
|
|
100
|
+
if [ "$IS_LISA_SELF" != "true" ] && [ -z "${CI:-}" ]; then
|
|
38
101
|
if ! node "$LISA_DIR/dist/index.js" --yes --skip-git-check "$PROJECT_ROOT"; then
|
|
39
102
|
echo "⚠️ Warning: Lisa template application failed. Migration may be incomplete." >&2
|
|
40
103
|
fi
|
|
41
104
|
fi
|
|
42
105
|
|
|
106
|
+
if [ "$IS_LISA_SELF" != "true" ]; then
|
|
107
|
+
install_codex_lisa_plugins
|
|
108
|
+
fi
|
|
109
|
+
|
|
43
110
|
# Strip only hook entries that reference deleted .claude/hooks/*.sh scripts
|
|
44
111
|
# (hooks moved to plugin.json; file-path hooks would produce "No such file or directory" errors).
|
|
45
112
|
# Preserve inline command hooks (e.g. `command -v entire ...`, `echo ...`) and stack-template hooks
|
|
46
113
|
# from rails/merge/.claude/settings.json.
|
|
47
|
-
SETTINGS_FILE
|
|
48
|
-
if [ -f "$SETTINGS_FILE" ] && command -v python3 >/dev/null 2>&1; then
|
|
114
|
+
if [ "$IS_LISA_SELF" != "true" ] && [ -f "$SETTINGS_FILE" ] && command -v python3 >/dev/null 2>&1; then
|
|
49
115
|
python3 - "$SETTINGS_FILE" <<'PYEOF'
|
|
50
116
|
import json, sys
|
|
51
117
|
path = sys.argv[1]
|
|
@@ -132,7 +198,7 @@ fi
|
|
|
132
198
|
# here — but ONLY the Lisa-shipped file (identified by its marker rule name), so a
|
|
133
199
|
# project's own hand-authored `.safety-net.json` is never touched.
|
|
134
200
|
LEGACY_SAFETY_NET="$PROJECT_ROOT/.safety-net.json"
|
|
135
|
-
if [ -f "$LEGACY_SAFETY_NET" ] && command -v jq >/dev/null 2>&1; then
|
|
201
|
+
if [ "$IS_LISA_SELF" != "true" ] && [ -f "$LEGACY_SAFETY_NET" ] && command -v jq >/dev/null 2>&1; then
|
|
136
202
|
if jq -e '
|
|
137
203
|
(.rules | type == "array")
|
|
138
204
|
and ([.rules[]?.name] | index("block-git-commit-no-verify") != null)
|
|
@@ -255,15 +321,7 @@ fi
|
|
|
255
321
|
claude plugin install "lisa@lisa" --scope project </dev/null 2>&1 || true
|
|
256
322
|
|
|
257
323
|
# Detect which stack plugin to install from .claude/settings.json
|
|
258
|
-
LISA_STACK=""
|
|
259
|
-
if [ -f "$SETTINGS_FILE" ] && command -v jq >/dev/null 2>&1; then
|
|
260
|
-
for stack in expo nestjs cdk harper-fabric rails; do
|
|
261
|
-
if jq -e "(.enabledPlugins // {}) | has(\"lisa-${stack}@lisa\")" "$SETTINGS_FILE" >/dev/null 2>&1; then
|
|
262
|
-
LISA_STACK="$stack"
|
|
263
|
-
break
|
|
264
|
-
fi
|
|
265
|
-
done
|
|
266
|
-
fi
|
|
324
|
+
LISA_STACK="$(detect_lisa_stack "$SETTINGS_FILE")"
|
|
267
325
|
|
|
268
326
|
# Install typescript layer for all TS-based stacks (everything except rails)
|
|
269
327
|
case "$LISA_STACK" in
|