@codyswann/lisa 4.45.16 → 4.46.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.
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/nightly-e2e-guard-behavior-certificate.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +26 -4
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +4 -4
- package/plugins/lisa/.claude-plugin/plugin.json +19 -1
- package/plugins/lisa/.codex-plugin/hooks.json +18 -0
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-drive-pr-to-merge/SKILL.md +24 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-queue-status/SKILL.md +24 -0
- package/plugins/lisa/hooks/enforcement-vintage.mjs +453 -0
- package/plugins/lisa/hooks/enforcement-vintage.sh +48 -0
- package/plugins/lisa/scripts/pr-arming-sweep.mjs +561 -0
- package/plugins/lisa/skills/lisa-drive-pr-to-merge/SKILL.md +24 -0
- package/plugins/lisa/skills/lisa-queue-status/SKILL.md +24 -0
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/scripts/pr-arming-sweep.mjs +561 -0
- package/plugins/lisa-agy/skills/lisa-drive-pr-to-merge/SKILL.md +24 -0
- package/plugins/lisa-agy/skills/lisa-queue-status/SKILL.md +24 -0
- 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 +10 -1
- package/plugins/lisa-copilot/hooks/enforcement-vintage.mjs +453 -0
- package/plugins/lisa-copilot/hooks/enforcement-vintage.sh +48 -0
- package/plugins/lisa-copilot/scripts/pr-arming-sweep.mjs +561 -0
- package/plugins/lisa-copilot/skills/lisa-drive-pr-to-merge/SKILL.md +24 -0
- package/plugins/lisa-copilot/skills/lisa-queue-status/SKILL.md +24 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/enforcement-vintage.mjs +453 -0
- package/plugins/lisa-cursor/hooks/enforcement-vintage.sh +48 -0
- package/plugins/lisa-cursor/hooks/hooks.json +6 -0
- package/plugins/lisa-cursor/scripts/pr-arming-sweep.mjs +561 -0
- package/plugins/lisa-cursor/skills/lisa-drive-pr-to-merge/SKILL.md +24 -0
- package/plugins/lisa-cursor/skills/lisa-queue-status/SKILL.md +24 -0
- 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/plugins/src/base/.claude-plugin/plugin.json +18 -0
- package/plugins/src/base/hooks/enforcement-vintage.mjs +453 -0
- package/plugins/src/base/hooks/enforcement-vintage.sh +48 -0
- package/plugins/src/base/scripts/pr-arming-sweep.mjs +561 -0
- package/plugins/src/base/skills/lisa-drive-pr-to-merge/SKILL.md +24 -0
- package/plugins/src/base/skills/lisa-queue-status/SKILL.md +24 -0
- package/scripts/lib/per-agent-hook-filter.mjs +19 -0
package/package.json
CHANGED
|
@@ -176,7 +176,7 @@
|
|
|
176
176
|
"zod-validation-error": "^4.0.0"
|
|
177
177
|
},
|
|
178
178
|
"name": "@codyswann/lisa",
|
|
179
|
-
"version": "4.
|
|
179
|
+
"version": "4.46.0",
|
|
180
180
|
"description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
|
|
181
181
|
"main": "dist/index.js",
|
|
182
182
|
"exports": {
|
|
@@ -324,7 +324,7 @@
|
|
|
324
324
|
"test": "tests"
|
|
325
325
|
},
|
|
326
326
|
"types": "./dist/index.d.ts",
|
|
327
|
-
"lisaReleaseCommit": "
|
|
328
|
-
"gitHead": "
|
|
329
|
-
"lisaReleaseTag": "v4.
|
|
327
|
+
"lisaReleaseCommit": "37f59b7109a6d4847bc7b9af4a8ae025c9a09c36",
|
|
328
|
+
"gitHead": "37f59b7109a6d4847bc7b9af4a8ae025c9a09c36",
|
|
329
|
+
"lisaReleaseTag": "v4.46.0"
|
|
330
330
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lisa",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.46.0",
|
|
4
4
|
"description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Cody Swann"
|
|
@@ -267,6 +267,15 @@
|
|
|
267
267
|
}
|
|
268
268
|
]
|
|
269
269
|
},
|
|
270
|
+
{
|
|
271
|
+
"matcher": "",
|
|
272
|
+
"hooks": [
|
|
273
|
+
{
|
|
274
|
+
"type": "command",
|
|
275
|
+
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/enforcement-vintage.sh"
|
|
276
|
+
}
|
|
277
|
+
]
|
|
278
|
+
},
|
|
270
279
|
{
|
|
271
280
|
"matcher": "",
|
|
272
281
|
"hooks": [
|
|
@@ -331,6 +340,15 @@
|
|
|
331
340
|
}
|
|
332
341
|
]
|
|
333
342
|
},
|
|
343
|
+
{
|
|
344
|
+
"matcher": "",
|
|
345
|
+
"hooks": [
|
|
346
|
+
{
|
|
347
|
+
"type": "command",
|
|
348
|
+
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/enforcement-vintage.sh"
|
|
349
|
+
}
|
|
350
|
+
]
|
|
351
|
+
},
|
|
334
352
|
{
|
|
335
353
|
"matcher": "",
|
|
336
354
|
"hooks": [
|
|
@@ -189,6 +189,15 @@
|
|
|
189
189
|
}
|
|
190
190
|
]
|
|
191
191
|
},
|
|
192
|
+
{
|
|
193
|
+
"matcher": "",
|
|
194
|
+
"hooks": [
|
|
195
|
+
{
|
|
196
|
+
"type": "command",
|
|
197
|
+
"command": "${PLUGIN_ROOT}/hooks/enforcement-vintage.sh"
|
|
198
|
+
}
|
|
199
|
+
]
|
|
200
|
+
},
|
|
192
201
|
{
|
|
193
202
|
"matcher": "",
|
|
194
203
|
"hooks": [
|
|
@@ -244,6 +253,15 @@
|
|
|
244
253
|
}
|
|
245
254
|
]
|
|
246
255
|
},
|
|
256
|
+
{
|
|
257
|
+
"matcher": "",
|
|
258
|
+
"hooks": [
|
|
259
|
+
{
|
|
260
|
+
"type": "command",
|
|
261
|
+
"command": "${PLUGIN_ROOT}/hooks/enforcement-vintage.sh"
|
|
262
|
+
}
|
|
263
|
+
]
|
|
264
|
+
},
|
|
247
265
|
{
|
|
248
266
|
"matcher": "",
|
|
249
267
|
"hooks": [
|
|
@@ -229,6 +229,30 @@ fi
|
|
|
229
229
|
gh pr view <pr> --json autoMergeRequest -q .autoMergeRequest # must print null
|
|
230
230
|
```
|
|
231
231
|
|
|
232
|
+
**Then declare the hold**, so the deliberate case stays distinguishable from the
|
|
233
|
+
defect. `/lisa:queue-status`'s arming sweep (#3903) reports every open PR whose
|
|
234
|
+
`autoMergeRequest` is `null`, because a green unarmed PR waits forever and no
|
|
235
|
+
other surface says so. An undeclared deliberate hold appears there as a finding
|
|
236
|
+
on every run, and a report that is wrong every run is one operators learn to
|
|
237
|
+
ignore:
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
gh pr edit <pr> --add-label "lisa:auto-merge-off"
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Where that label does not exist in the repo, put the marker in the PR body
|
|
244
|
+
instead — the sweep reads either spelling — and **give it a reason**, because
|
|
245
|
+
the sweep prints one and prints `no reason declared` when it cannot:
|
|
246
|
+
|
|
247
|
+
```text
|
|
248
|
+
<!-- [lisa-auto-merge-off] reason=<why a human owns this merge> -->
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Declaring the hold **suppresses it from the findings, not from the report**: the
|
|
252
|
+
sweep still counts and names every held PR. That is deliberate — a label that
|
|
253
|
+
turned a red sweep green and left no trace would be a bypass wearing the costume
|
|
254
|
+
of a fix.
|
|
255
|
+
|
|
232
256
|
If the disarm fails or the re-read still shows an armed `autoMergeRequest`,
|
|
233
257
|
**fail closed**: treat the PR as a hard block (section 4) and report that the
|
|
234
258
|
`awaiting-human` state was NOT reached — never proceed to a state in which the
|
|
@@ -69,6 +69,30 @@ For each inspected queue, report:
|
|
|
69
69
|
|
|
70
70
|
The report should stay terminal-first and immediately actionable: observable queue facts first, then the smallest useful next step.
|
|
71
71
|
|
|
72
|
+
## Pull request arming (#3903)
|
|
73
|
+
|
|
74
|
+
Alongside the two work queues, report the **arming state of the repo's open pull requests**. A PR whose `autoMergeRequest` is `null` is fully green and permanently unmergeable: every check passes, `mergeStateStatus` is green, nothing complains, and it waits forever. Green-and-unarmed and green-and-waiting read identically on every other surface, so this is the one place that asks the question.
|
|
75
|
+
|
|
76
|
+
Run the sweep rather than eyeballing the list — an LLM reading PR pages one at a time is exactly the shell-loop-by-prose failure #3512 records:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
gh pr list --state open --limit 200 \
|
|
80
|
+
--json number,title,url,isDraft,labels,body,autoMergeRequest \
|
|
81
|
+
| node "${CLAUDE_PLUGIN_ROOT}/scripts/pr-arming-sweep.mjs"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Report the verdict verbatim, and never translate it into an absence:
|
|
85
|
+
|
|
86
|
+
- `MEASURED_CLEAN` — a **positive assertion**: N open PRs were read and every one is armed or deliberately unarmed. Say the count. "Nothing to report" is not the same sentence and must not be substituted for it.
|
|
87
|
+
- `UNARMED_PRS_FOUND` — list each PR. The next step is `gh pr merge <n> --auto --merge` **followed by reading the state back** (`gh pr view <n> --json autoMergeRequest`); arming can be silently dropped after the fact, so an arm that was not read back is not a fact.
|
|
88
|
+
- `NOT_MEASURED` — the arming state was **not read** for at least one PR (usually a `--json` selection missing `autoMergeRequest`). This is an unanswered question, not a clean queue. Fix the query and re-run; do **not** report the queue as healthy.
|
|
89
|
+
|
|
90
|
+
Arming state feeds no queue verdict and gates nothing — this stays a report, per #3903's Out of Scope. Never arm a PR from this skill; `/lisa:queue-status` is read-only.
|
|
91
|
+
|
|
92
|
+
A PR deliberately left for a human (`lisa-drive-pr-to-merge`'s `auto_merge=false` mode) declares itself with the `lisa:auto-merge-off` label or a `[lisa-auto-merge-off] reason=<text>` body marker. Drafts are excluded — GitHub will not arm a draft.
|
|
93
|
+
|
|
94
|
+
**Held PRs are suppressed from the findings, never from the report.** Always show the `Held (declared, not merging): N` block and the numbers under it, including when the verdict is `MEASURED_CLEAN`. This is a one-label remedy for a red sweep and it will look like housekeeping to whoever applies it — "4 armed, 0 unarmed" and "4 armed, 0 unarmed, 9 held" describe very different queues, and only the second lets an operator notice the label spreading. Flag it when holds outnumber armed PRs, or when several read `no reason declared`.
|
|
95
|
+
|
|
72
96
|
## Highlight semantics
|
|
73
97
|
|
|
74
98
|
Each queue section may include one or more highlighted items. A highlight is not a raw dump of every issue in that role; it is the single oldest or otherwise most actionable item Lisa can justify surfacing without mutating work.
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionStart / SubagentStart: tell the agent WHICH COPY of Lisa is executing
|
|
3
|
+
* in this session, and whether a newer one is sitting on the same disk.
|
|
4
|
+
*
|
|
5
|
+
* The defect this closes (CodySwannGT/lisa#3714). A session resolves its Lisa
|
|
6
|
+
* copy ONCE, at session start, and executes that copy for its whole life. On
|
|
7
|
+
* Claude that is `~/.claude/plugins/cache/lisa/lisa/<version>/`; in the Lisa
|
|
8
|
+
* monorepo the repository hook dispatcher additionally resolves guards out of
|
|
9
|
+
* the CHECKOUT. Neither is refreshed by anything that happens afterwards:
|
|
10
|
+
* merging to `main`, publishing to npm, updating the marketplace, or running
|
|
11
|
+
* `lisa apply` all leave a running session on the copy it started with.
|
|
12
|
+
*
|
|
13
|
+
* So `origin/main` contains a fix is a fact about the repository, and never a
|
|
14
|
+
* fact about this session. Measured on this fleet: a lane eleven hours old was
|
|
15
|
+
* executing 4.32.2 while `main` and the marketplace clone were at 4.35.1, with
|
|
16
|
+
* 4.35.1 already sitting in the cache beside 310 other versions. It is not a
|
|
17
|
+
* distribution failure — the newer copy had arrived and could not be reached.
|
|
18
|
+
*
|
|
19
|
+
* The consequence was measured twice, in OPPOSITE directions, by careful agents
|
|
20
|
+
* holding real measurements:
|
|
21
|
+
*
|
|
22
|
+
* - A lane read an already-fixed defect out of its stale copy, believed it
|
|
23
|
+
* live, and filed a ticket for it.
|
|
24
|
+
* - Another lane nearly shipped against a superseded contract, because the
|
|
25
|
+
* contract it read was the stale copy's.
|
|
26
|
+
*
|
|
27
|
+
* One cause, two opposite errors, and NEITHER was visible from inside the
|
|
28
|
+
* session. That invisibility is the whole defect: the version was knowable at
|
|
29
|
+
* every moment and nothing ever said it.
|
|
30
|
+
*
|
|
31
|
+
* Why this is context and not a refusal. The obvious remedy — refuse, or
|
|
32
|
+
* restart the fleet — is not free here: sessions hold uncommitted sole-copy
|
|
33
|
+
* work, so a forced restart can cost more than the staleness it cures. A
|
|
34
|
+
* session that can SEE it is stale can say so, discount its own evidence, and
|
|
35
|
+
* verify against `origin/main` before reporting — and that costs nothing. The
|
|
36
|
+
* enforcement fallback already computes a version comparison and prints it to
|
|
37
|
+
* stderr; on the exit-0 path Claude Code shows a hook's stderr to the user in
|
|
38
|
+
* transcript mode only and NEVER to the agent, so the agent — the party that
|
|
39
|
+
* acts on it — was the one party never told. Injected context is the channel
|
|
40
|
+
* that reaches it.
|
|
41
|
+
*
|
|
42
|
+
* Why it always speaks, including when current. Silence would be ambiguous
|
|
43
|
+
* between "this copy is current" and "the hook did not run", and an ambiguous
|
|
44
|
+
* signal is the failure mode this file exists to remove. A current session gets
|
|
45
|
+
* one short line; a stale one gets the delta and what to do about it.
|
|
46
|
+
*
|
|
47
|
+
* FAIL SOFT, ALWAYS. Every exit is 0. A session that cannot date itself must
|
|
48
|
+
* still start.
|
|
49
|
+
* @module plugins/src/base/hooks/enforcement-vintage
|
|
50
|
+
*/
|
|
51
|
+
import { readFileSync, existsSync } from "fs";
|
|
52
|
+
import path from "path";
|
|
53
|
+
import { fileURLToPath } from "url";
|
|
54
|
+
import { realpathSync } from "fs";
|
|
55
|
+
|
|
56
|
+
/** Marker the block is wrapped in, so an agent can cite it by name. */
|
|
57
|
+
const BLOCK_TAG = "lisa-enforcement-vintage";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The version a JSON manifest states at its TOP LEVEL.
|
|
61
|
+
*
|
|
62
|
+
* Parsed rather than pattern-matched, which is the one place this renderer
|
|
63
|
+
* should not copy the Bash dispatcher beside it. That dispatcher runs on every
|
|
64
|
+
* tool call under bash 3.2 with no JSON parser available, so it approximates
|
|
65
|
+
* "top-level" by anchoring a line-oriented regex to at most three leading
|
|
66
|
+
* spaces — an approximation that fails outright on a minified manifest, where
|
|
67
|
+
* every key is on one line. Node has a real parser and this runs once per
|
|
68
|
+
* session, so it reads the key it means: `node_modules/@codyswann/lisa/package.json`
|
|
69
|
+
* carries nested `"version"` keys inside its dependency block, and only a parse
|
|
70
|
+
* distinguishes those from Lisa's own in every formatting.
|
|
71
|
+
*
|
|
72
|
+
* The anchored regex is kept as a FALLBACK, for the one input a parse cannot
|
|
73
|
+
* serve: a manifest that is malformed. A file that is broken but pretty-printed
|
|
74
|
+
* still dates its copy, and a copy that cannot be dated is reported as unknown
|
|
75
|
+
* rather than assumed current.
|
|
76
|
+
* @param {string} file Absolute path to a JSON file.
|
|
77
|
+
* @param {string} key Top-level key holding the version.
|
|
78
|
+
* @returns {string} The version, or "" when the file or key is absent.
|
|
79
|
+
*/
|
|
80
|
+
export function readJsonVersion(file, key) {
|
|
81
|
+
let raw = "";
|
|
82
|
+
try {
|
|
83
|
+
raw = readFileSync(file, "utf8");
|
|
84
|
+
} catch {
|
|
85
|
+
return "";
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const parsed = JSON.parse(raw);
|
|
89
|
+
const value = parsed?.[key];
|
|
90
|
+
if (typeof value === "string" && value) return value;
|
|
91
|
+
return "";
|
|
92
|
+
} catch {
|
|
93
|
+
const pattern = new RegExp(`^ {0,3}"${key}"\\s*:\\s*"([^"]+)"`, "m");
|
|
94
|
+
const match = pattern.exec(raw);
|
|
95
|
+
return match ? match[1] : "";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Numeric release fields of a version string, ignoring prerelease and build
|
|
101
|
+
* suffixes.
|
|
102
|
+
*
|
|
103
|
+
* A field that is not a plain number becomes 0 rather than throwing, so a
|
|
104
|
+
* version string this does not understand can never be reported as NEWER than
|
|
105
|
+
* one it does. That direction is deliberate: the failure mode is silence, not
|
|
106
|
+
* a fleet-wide false staleness alarm.
|
|
107
|
+
* @param {string} version A dotted version string.
|
|
108
|
+
* @returns {readonly number[]} Three release fields.
|
|
109
|
+
*/
|
|
110
|
+
export function releaseFields(version) {
|
|
111
|
+
const core = String(version).split("-")[0].split("+")[0];
|
|
112
|
+
const parts = core.split(".").slice(0, 3);
|
|
113
|
+
return [0, 1, 2].map(index => {
|
|
114
|
+
const field = parts[index];
|
|
115
|
+
return /^\d+$/u.test(field ?? "") ? Number(field) : 0;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Whether the first version names an older Lisa than the second.
|
|
121
|
+
* @param {string} a Candidate older version.
|
|
122
|
+
* @param {string} b Candidate newer version.
|
|
123
|
+
* @returns {boolean} True when `a` precedes `b`.
|
|
124
|
+
*/
|
|
125
|
+
export function isOlder(a, b) {
|
|
126
|
+
const left = releaseFields(a);
|
|
127
|
+
const right = releaseFields(b);
|
|
128
|
+
for (let index = 0; index < 3; index += 1) {
|
|
129
|
+
if (left[index] !== right[index]) return left[index] < right[index];
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The newest of a set of dated copies.
|
|
136
|
+
*
|
|
137
|
+
* A maximum over local evidence, never one nominated reference — the same
|
|
138
|
+
* choice the Bash dispatcher makes, and load-bearing in both directions. In the
|
|
139
|
+
* Lisa monorepo `node_modules/@codyswann/lisa` is a fixture pinned majors
|
|
140
|
+
* behind the repository, so treating it as "the installed release" would report
|
|
141
|
+
* every current checkout as ahead and never report a stale one. In a host
|
|
142
|
+
* project it is the most meaningful reference available. A maximum needs no
|
|
143
|
+
* ranking between them and cannot invent staleness: a copy is behind only when
|
|
144
|
+
* something demonstrably newer sits on the same disk.
|
|
145
|
+
* @param {readonly {version: string, source: string}[]} copies Dated copies.
|
|
146
|
+
* @returns {{version: string, source: string} | null} The newest, or null.
|
|
147
|
+
*/
|
|
148
|
+
export function newestOf(copies) {
|
|
149
|
+
return copies
|
|
150
|
+
.filter(copy => copy.version)
|
|
151
|
+
.reduce(
|
|
152
|
+
(best, copy) =>
|
|
153
|
+
best === null || isOlder(best.version, copy.version) ? copy : best,
|
|
154
|
+
/** @type {{version: string, source: string} | null} */ (null)
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Date the copy this hook is itself executing from.
|
|
160
|
+
*
|
|
161
|
+
* Located from the script's OWN path rather than from an environment variable,
|
|
162
|
+
* and that is the point. `CLAUDE_PLUGIN_ROOT` is a claim the harness makes;
|
|
163
|
+
* the directory the file was loaded out of is not a claim at all. A copy that
|
|
164
|
+
* dates itself cannot be misattributed by a variable that is unset, stale, or
|
|
165
|
+
* pointing somewhere else.
|
|
166
|
+
* @param {string} hooksDir Directory holding this script.
|
|
167
|
+
* @returns {{version: string, source: string, root: string}} The running copy.
|
|
168
|
+
*/
|
|
169
|
+
export function runningCopy(hooksDir) {
|
|
170
|
+
const root = path.dirname(hooksDir);
|
|
171
|
+
const manifest = path.join(root, ".claude-plugin", "plugin.json");
|
|
172
|
+
return {
|
|
173
|
+
version: readJsonVersion(manifest, "version"),
|
|
174
|
+
source: manifest,
|
|
175
|
+
root,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The guard tree the repository hook dispatcher would resolve, and its vintage.
|
|
181
|
+
*
|
|
182
|
+
* Resolution is first-wins: `scripts/lisa-hooks/` shadows `plugins/lisa/hooks/`
|
|
183
|
+
* outright and the shadowed copy never runs. So the tree reported here is
|
|
184
|
+
* whichever is FIRST, never whichever is newest — the distinction the whole
|
|
185
|
+
* ticket turns on. The host tree is dated by the apply receipt, because the
|
|
186
|
+
* same `lisa apply` run produced both and they cannot disagree; the monorepo's
|
|
187
|
+
* own tree is dated by the plugin manifest beside it.
|
|
188
|
+
* @param {string} projectDir Repository root.
|
|
189
|
+
* @returns {{tree: string, version: string, repair: string} | null} The tree.
|
|
190
|
+
*/
|
|
191
|
+
export function repositoryTree(projectDir) {
|
|
192
|
+
const hostTree = path.join(projectDir, "scripts", "lisa-hooks");
|
|
193
|
+
const pluginTree = path.join(projectDir, "plugins", "lisa", "hooks");
|
|
194
|
+
if (existsSync(path.join(hostTree, "block-no-verify.sh"))) {
|
|
195
|
+
return {
|
|
196
|
+
tree: hostTree,
|
|
197
|
+
version: readJsonVersion(
|
|
198
|
+
path.join(projectDir, ".lisa", "apply-receipt.json"),
|
|
199
|
+
"lisa_version"
|
|
200
|
+
),
|
|
201
|
+
repair: "`npx @codyswann/lisa apply` rewrites this tree",
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
if (existsSync(path.join(pluginTree, "block-no-verify.sh"))) {
|
|
205
|
+
return {
|
|
206
|
+
tree: pluginTree,
|
|
207
|
+
version: readJsonVersion(
|
|
208
|
+
path.join(
|
|
209
|
+
projectDir,
|
|
210
|
+
"plugins",
|
|
211
|
+
"lisa",
|
|
212
|
+
".claude-plugin",
|
|
213
|
+
"plugin.json"
|
|
214
|
+
),
|
|
215
|
+
"version"
|
|
216
|
+
),
|
|
217
|
+
repair:
|
|
218
|
+
"this tree IS the checkout's own source, so only moving the branch refreshes it",
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Every dated Lisa this machine can be SHOWN to hold.
|
|
226
|
+
* @param {string} projectDir Repository root.
|
|
227
|
+
* @param {string} configDir Harness config directory.
|
|
228
|
+
* @returns {readonly {version: string, source: string}[]} Dated copies.
|
|
229
|
+
*/
|
|
230
|
+
export function localCopies(projectDir, configDir) {
|
|
231
|
+
const marketplace = path.join(
|
|
232
|
+
configDir,
|
|
233
|
+
"plugins",
|
|
234
|
+
"marketplaces",
|
|
235
|
+
"lisa",
|
|
236
|
+
"plugins",
|
|
237
|
+
"lisa",
|
|
238
|
+
".claude-plugin",
|
|
239
|
+
"plugin.json"
|
|
240
|
+
);
|
|
241
|
+
const installed = path.join(
|
|
242
|
+
projectDir,
|
|
243
|
+
"node_modules",
|
|
244
|
+
"@codyswann",
|
|
245
|
+
"lisa",
|
|
246
|
+
"package.json"
|
|
247
|
+
);
|
|
248
|
+
const checkout = path.join(
|
|
249
|
+
projectDir,
|
|
250
|
+
"plugins",
|
|
251
|
+
"lisa",
|
|
252
|
+
".claude-plugin",
|
|
253
|
+
"plugin.json"
|
|
254
|
+
);
|
|
255
|
+
return [
|
|
256
|
+
{ version: readJsonVersion(marketplace, "version"), source: marketplace },
|
|
257
|
+
{ version: readJsonVersion(installed, "version"), source: installed },
|
|
258
|
+
{ version: readJsonVersion(checkout, "version"), source: checkout },
|
|
259
|
+
];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Everything the block needs, resolved from disk.
|
|
264
|
+
* @param {{hooksDir: string, projectDir: string, configDir: string}} where Paths.
|
|
265
|
+
* @returns {object} Resolved vintage state.
|
|
266
|
+
*/
|
|
267
|
+
export function resolveState(where) {
|
|
268
|
+
const running = runningCopy(where.hooksDir);
|
|
269
|
+
const tree = repositoryTree(where.projectDir);
|
|
270
|
+
|
|
271
|
+
// Two maxima, and the split is what lets AHEAD exist as its own answer.
|
|
272
|
+
//
|
|
273
|
+
// `reference` deliberately EXCLUDES the running copy, because a session
|
|
274
|
+
// cannot be evidence about itself: folding it in makes an ahead session
|
|
275
|
+
// select itself as the newest thing on the disk, which collapses "ahead of
|
|
276
|
+
// every other copy here" into "current" and loses the distinction entirely.
|
|
277
|
+
//
|
|
278
|
+
// `newest` includes it, because the reported ROW answers a different
|
|
279
|
+
// question — what is the newest Lisa on this disk — and on a machine where
|
|
280
|
+
// the session copy is the only dateable one, excluding it would leave that
|
|
281
|
+
// row empty and make a current session indistinguishable from an undateable
|
|
282
|
+
// one. Same numbers, two questions, and only the first may be self-referential.
|
|
283
|
+
const reference = newestOf([
|
|
284
|
+
...localCopies(where.projectDir, where.configDir),
|
|
285
|
+
...(tree ? [{ version: tree.version, source: tree.tree }] : []),
|
|
286
|
+
]);
|
|
287
|
+
const newest = newestOf([
|
|
288
|
+
...(reference ? [reference] : []),
|
|
289
|
+
{ version: running.version, source: running.source },
|
|
290
|
+
]);
|
|
291
|
+
const behind = Boolean(
|
|
292
|
+
reference && running.version && isOlder(running.version, reference.version)
|
|
293
|
+
);
|
|
294
|
+
const ahead = Boolean(
|
|
295
|
+
reference && running.version && isOlder(reference.version, running.version)
|
|
296
|
+
);
|
|
297
|
+
// Measured against the disk maximum rather than against the session copy: a
|
|
298
|
+
// session running an OLDER build than the tree it polices would otherwise
|
|
299
|
+
// report a nine-minor-old tree as fine.
|
|
300
|
+
const treeBehind = Boolean(
|
|
301
|
+
tree && newest && tree.version && isOlder(tree.version, newest.version)
|
|
302
|
+
);
|
|
303
|
+
return { running, tree, reference, newest, behind, ahead, treeBehind };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** What a stale session must do differently, stated as actions. */
|
|
307
|
+
const CONSEQUENCES = [
|
|
308
|
+
"A guard refusal or contract you read HERE may already be fixed or superseded on `origin/main`. Filing it as live is the error this measured twice tonight, in both directions.",
|
|
309
|
+
"Verify any claim about this repository's source with `git show origin/main:<path>` — never from a working tree — and say which vintage you read when you report it. A REFUTATION needs that provenance more than a claim does: a stale `grep` returns a clean, specific, checkable-looking negative.",
|
|
310
|
+
"Merge ancestry proves a fix EXISTS. It never proves the exposure is closed for a session already running. Report those two facts separately.",
|
|
311
|
+
"Only starting a new session re-resolves the copy. Publishing, `lisa apply`, and updating the marketplace do not reach this one.",
|
|
312
|
+
];
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Render the injected block.
|
|
316
|
+
* @param {object} state Resolved vintage state.
|
|
317
|
+
* @returns {string} Block text.
|
|
318
|
+
*/
|
|
319
|
+
export function renderBlock(state) {
|
|
320
|
+
const lines = [`<${BLOCK_TAG}>`];
|
|
321
|
+
lines.push(
|
|
322
|
+
"The Lisa copy executing in this session was resolved when the session started " +
|
|
323
|
+
"and never changes afterwards. What `origin/main` contains is not what runs here."
|
|
324
|
+
);
|
|
325
|
+
lines.push("");
|
|
326
|
+
lines.push(
|
|
327
|
+
`session copy: ${state.running.version ? `lisa ${state.running.version}` : "vintage unknown (no manifest beside it, so it cannot be shown current)"} at ${state.running.root}`
|
|
328
|
+
);
|
|
329
|
+
if (state.tree) {
|
|
330
|
+
lines.push(
|
|
331
|
+
`repository guards: ${state.tree.version ? `lisa ${state.tree.version}` : "vintage unknown"} at ${state.tree.tree}${state.treeBehind ? ` — BEHIND; ${state.tree.repair}` : ""}`
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
if (state.newest) {
|
|
335
|
+
lines.push(
|
|
336
|
+
`newest on this disk: lisa ${state.newest.version} at ${state.newest.source}`
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
lines.push("");
|
|
340
|
+
if (state.behind || !state.running.version || state.treeBehind) {
|
|
341
|
+
lines.push(
|
|
342
|
+
state.behind
|
|
343
|
+
? `STALE — this session executes lisa ${state.running.version} while lisa ${state.reference.version} is already on this machine. Act on that:`
|
|
344
|
+
: "UNPROVEN — this session cannot be shown to be current. Treat it as stale:"
|
|
345
|
+
);
|
|
346
|
+
CONSEQUENCES.forEach(line => lines.push(`- ${line}`));
|
|
347
|
+
} else if (state.ahead) {
|
|
348
|
+
// Not an alarm, and not the same as current. A session ahead of every
|
|
349
|
+
// released copy is running code no other session on this machine has, so
|
|
350
|
+
// guard behaviour observed here is evidence about an UNRELEASED build —
|
|
351
|
+
// which is the mirror of the stale reading and produces the same class of
|
|
352
|
+
// wrong report, in the other direction.
|
|
353
|
+
lines.push(
|
|
354
|
+
`AHEAD — this session executes lisa ${state.running.version}, newer than lisa ` +
|
|
355
|
+
`${state.reference.version} at ${state.reference.source}. Nothing here is stale, but ` +
|
|
356
|
+
"behaviour you observe may not exist yet for anyone else: do not report it as how " +
|
|
357
|
+
"Lisa behaves, and say which version you were on."
|
|
358
|
+
);
|
|
359
|
+
} else {
|
|
360
|
+
lines.push(
|
|
361
|
+
"CURRENT — no newer Lisa is on this disk, so guard behaviour and skill contracts " +
|
|
362
|
+
"observed here match the newest copy this machine holds. State the version anyway " +
|
|
363
|
+
"when you report on guard or contract behaviour; it is only current until the next release."
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
lines.push(`</${BLOCK_TAG}>`);
|
|
367
|
+
return lines.join("\n");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Read the hook event name from the harness's stdin payload.
|
|
372
|
+
* @param {string} raw Raw stdin.
|
|
373
|
+
* @returns {string} The event name, defaulting to `SessionStart`.
|
|
374
|
+
*/
|
|
375
|
+
export function eventName(raw) {
|
|
376
|
+
try {
|
|
377
|
+
const parsed = JSON.parse(raw);
|
|
378
|
+
return typeof parsed?.hook_event_name === "string"
|
|
379
|
+
? parsed.hook_event_name
|
|
380
|
+
: "SessionStart";
|
|
381
|
+
} catch {
|
|
382
|
+
return "SessionStart";
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Emit the hook envelope. Always exits 0 — see the module comment.
|
|
388
|
+
* @param {string[]} argv Process arguments.
|
|
389
|
+
* @param {string} stdin Raw stdin.
|
|
390
|
+
* @param {NodeJS.ProcessEnv} [env] Environment.
|
|
391
|
+
* @returns {string} The JSON envelope.
|
|
392
|
+
*/
|
|
393
|
+
export function run(argv, stdin, env = process.env) {
|
|
394
|
+
const flagValue = name => {
|
|
395
|
+
const index = argv.indexOf(name);
|
|
396
|
+
return index >= 0 ? argv[index + 1] : "";
|
|
397
|
+
};
|
|
398
|
+
const hooksDir =
|
|
399
|
+
flagValue("--hooks-dir") || path.dirname(fileURLToPath(import.meta.url));
|
|
400
|
+
const projectDir = flagValue("--project-dir") || process.cwd();
|
|
401
|
+
const configDir =
|
|
402
|
+
flagValue("--config-dir") ||
|
|
403
|
+
env.CLAUDE_CONFIG_DIR ||
|
|
404
|
+
path.join(env.HOME || "", ".claude");
|
|
405
|
+
return JSON.stringify({
|
|
406
|
+
hookSpecificOutput: {
|
|
407
|
+
hookEventName: eventName(stdin),
|
|
408
|
+
additionalContext: renderBlock(
|
|
409
|
+
resolveState({ hooksDir, projectDir, configDir })
|
|
410
|
+
),
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Whether this module is the process entry point.
|
|
417
|
+
*
|
|
418
|
+
* Both sides are realpath'd: comparing `fileURLToPath(import.meta.url)` against
|
|
419
|
+
* `path.resolve(process.argv[1])` disagrees whenever the checkout is reached
|
|
420
|
+
* through a symlink — a git worktree, or any `/tmp` path on macOS, `/tmp` being
|
|
421
|
+
* a symlink to `/private/tmp` — because `import.meta.url` is the REAL path
|
|
422
|
+
* while `argv[1]` is whatever the caller typed.
|
|
423
|
+
*
|
|
424
|
+
* The one implementation lives at `scripts/lib/invoked-as-script.mjs`. This
|
|
425
|
+
* file cannot import it: it is materialized into every plugin payload, where
|
|
426
|
+
* there is no `./lib/` to import from — the same accommodation
|
|
427
|
+
* `inject-resolved-config.mjs` makes.
|
|
428
|
+
* @param {string} moduleUrl The caller's own `import.meta.url`.
|
|
429
|
+
* @param {string | undefined} [argv1] Entry path; defaults to `process.argv[1]`.
|
|
430
|
+
* @returns {boolean} Whether the caller should run its CLI body.
|
|
431
|
+
*/
|
|
432
|
+
export function invokedAsScript(moduleUrl, argv1 = process.argv[1]) {
|
|
433
|
+
if (!argv1) return false;
|
|
434
|
+
try {
|
|
435
|
+
return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
|
|
436
|
+
} catch {
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (invokedAsScript(import.meta.url)) {
|
|
442
|
+
let stdin = "";
|
|
443
|
+
try {
|
|
444
|
+
stdin = readFileSync(0, "utf8");
|
|
445
|
+
} catch {
|
|
446
|
+
stdin = "";
|
|
447
|
+
}
|
|
448
|
+
try {
|
|
449
|
+
process.stdout.write(`${run(process.argv.slice(2), stdin)}\n`);
|
|
450
|
+
} catch {
|
|
451
|
+
// Fail soft: a hook that cannot render must never wedge session start.
|
|
452
|
+
}
|
|
453
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# SessionStart / SubagentStart: put the VINTAGE of the executing Lisa copy in
|
|
3
|
+
# front of the agent (CodySwannGT/lisa#3714).
|
|
4
|
+
#
|
|
5
|
+
# A session resolves its Lisa copy once, at session start, and runs that copy
|
|
6
|
+
# until it ends. Nothing that happens afterwards — a merge to `main`, a publish,
|
|
7
|
+
# a marketplace update, a `lisa apply` — reaches it. So "the fix is on `main`"
|
|
8
|
+
# is a fact about the repository and never a fact about this session, and until
|
|
9
|
+
# now nothing in the session said which of the two it was looking at.
|
|
10
|
+
#
|
|
11
|
+
# All the work lives in the sibling `.mjs`, which is unit-tested. This wrapper
|
|
12
|
+
# only resolves the project root and hands stdin through — the same division
|
|
13
|
+
# `inject-resolved-config.sh` beside it makes.
|
|
14
|
+
#
|
|
15
|
+
# `--hooks-dir` is deliberately NOT passed. The renderer dates the copy from its
|
|
16
|
+
# own `import.meta.url`, which is where the file was actually loaded from; a
|
|
17
|
+
# path this wrapper computed from an environment variable would be a claim about
|
|
18
|
+
# which copy is running rather than an observation of it, and this whole file
|
|
19
|
+
# exists because such a claim was wrong for eleven hours without anyone noticing.
|
|
20
|
+
#
|
|
21
|
+
# FAIL SOFT, ALWAYS. Every exit is 0 and every failure is silent. The right
|
|
22
|
+
# response to "node is missing" is a session that starts without this block,
|
|
23
|
+
# never a session that does not start. The hard reading of the same question
|
|
24
|
+
# lives where it can afford to be hard: `lisa doctor` reports what a checkout
|
|
25
|
+
# resolves and exits non-zero on it.
|
|
26
|
+
set -uo pipefail
|
|
27
|
+
|
|
28
|
+
INPUT=$(cat 2>/dev/null || true)
|
|
29
|
+
|
|
30
|
+
ROOT="${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}}"
|
|
31
|
+
RENDERER="$ROOT/hooks/enforcement-vintage.mjs"
|
|
32
|
+
|
|
33
|
+
[ -f "$RENDERER" ] || exit 0
|
|
34
|
+
command -v node >/dev/null 2>&1 || exit 0
|
|
35
|
+
|
|
36
|
+
# CLAUDE_PROJECT_DIR is the harness's own declaration of the root and is the
|
|
37
|
+
# only source that stays correct when the session's cwd has moved. Falling back
|
|
38
|
+
# to the git toplevel before $PWD matters for the same reason it does in
|
|
39
|
+
# `inject-resolved-config.sh`: the artifacts this dates live at the repository
|
|
40
|
+
# root, and a hook that reads the cwd reports the wrong tree from any
|
|
41
|
+
# subdirectory.
|
|
42
|
+
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-}"
|
|
43
|
+
if [ -z "${PROJECT_DIR}" ] || [ ! -d "${PROJECT_DIR}" ]; then
|
|
44
|
+
PROJECT_DIR="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
printf '%s' "$INPUT" | node "$RENDERER" --project-dir "$PROJECT_DIR" || true
|
|
48
|
+
exit 0
|