@codyswann/lisa 3.51.1 → 3.51.3
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/all/copy-overwrite/scripts/lisa-hooks/block-direct-issue-create.sh +271 -12
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +3 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +8 -5
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/opencode/plugin-templates/lisa-block-direct-issue-create.ts +162 -9
- package/expo/copy-overwrite/scripts/bdd/baseline.mjs +17 -0
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/hooks/block-direct-issue-create.sh +271 -12
- package/plugins/lisa/rules/eager/ready-role-filing.md +2 -0
- package/plugins/lisa/rules/reference/ready-role-filing.md +27 -0
- package/plugins/lisa-agy/hooks/block-direct-issue-create.sh +271 -12
- 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-copilot/hooks/block-direct-issue-create.sh +271 -12
- package/plugins/lisa-copilot/rules/eager/ready-role-filing.md +2 -0
- package/plugins/lisa-copilot/rules/reference/ready-role-filing.md +27 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/block-direct-issue-create.sh +271 -12
- package/plugins/lisa-cursor/rules/ready-role-filing-reference.mdc +27 -0
- package/plugins/lisa-cursor/rules/ready-role-filing.mdc +2 -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/hooks/block-direct-issue-create.sh +271 -12
- package/plugins/src/base/rules/eager/ready-role-filing.md +2 -0
- package/plugins/src/base/rules/reference/ready-role-filing.md +27 -0
|
@@ -43,6 +43,16 @@ const LisaBlockDirectIssueCreate = async () => {
|
|
|
43
43
|
*/
|
|
44
44
|
const LABEL_FLAG =
|
|
45
45
|
/--(?:label|labels|add-label|status|state)(?:=|\s+)(['"]?)([^'"\s]+)\1/g;
|
|
46
|
+
/**
|
|
47
|
+
* The repository a creation is ADDRESSED at, which decides whose ready role
|
|
48
|
+
* answers for it. `-R` is honored here even though short flags are refused
|
|
49
|
+
* for labels above: `--repo`/`-R` is one flag on one CLI with one meaning,
|
|
50
|
+
* where the label short forms collide across trackers.
|
|
51
|
+
*/
|
|
52
|
+
const REPO_FLAG = /(?:^|\s)(?:--repo|-R)(?:=|\s+)(['"]?)([^'"\s]+)\1/;
|
|
53
|
+
const ISSUES_ENDPOINT = /repos\/([^/\s]+)\/([^/\s]+)\/issues\b/;
|
|
54
|
+
const DEFAULT_READY_ROLE = "status:ready";
|
|
55
|
+
const DEFAULT_UPSTREAM_REPO = "CodySwannGT/lisa";
|
|
46
56
|
const CREATION_SIGNATURES: readonly {
|
|
47
57
|
readonly re: RegExp;
|
|
48
58
|
readonly name: string;
|
|
@@ -85,9 +95,35 @@ const LisaBlockDirectIssueCreate = async () => {
|
|
|
85
95
|
|
|
86
96
|
interface LisaConfig {
|
|
87
97
|
tracker?: string;
|
|
88
|
-
github?: {
|
|
98
|
+
github?: {
|
|
99
|
+
org?: string;
|
|
100
|
+
repo?: string;
|
|
101
|
+
labels?: { build?: { ready?: string } };
|
|
102
|
+
};
|
|
89
103
|
jira?: { workflow?: { ready?: string } };
|
|
90
104
|
linear?: { workflow?: { ready?: string } };
|
|
105
|
+
hardening?: { upstreamRepo?: string; upstreamReadyRole?: string };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Everything the guard needs to decide whose ready role answers. */
|
|
109
|
+
interface FilingPolicy {
|
|
110
|
+
/** The calling project's own build-ready role. */
|
|
111
|
+
readonly readyRole: string;
|
|
112
|
+
/** The calling project's own repository, when it declares one. */
|
|
113
|
+
readonly ownRepo: string | undefined;
|
|
114
|
+
/** The repository upstream defects are filed at. */
|
|
115
|
+
readonly upstreamRepo: string;
|
|
116
|
+
/** The ready role that repository runs its build queue off. */
|
|
117
|
+
readonly upstreamReadyRole: string;
|
|
118
|
+
/** Whether the caller's ready role is a GitHub label at all. */
|
|
119
|
+
readonly callerIsGithub: boolean;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Which roles satisfy a filing, and the target a refusal should name. */
|
|
123
|
+
interface Verdict {
|
|
124
|
+
readonly roles: readonly string[];
|
|
125
|
+
/** Set only when the filing is provably addressed at another repository. */
|
|
126
|
+
readonly named: string | undefined;
|
|
91
127
|
}
|
|
92
128
|
|
|
93
129
|
/**
|
|
@@ -104,7 +140,7 @@ const LisaBlockDirectIssueCreate = async () => {
|
|
|
104
140
|
};
|
|
105
141
|
|
|
106
142
|
/**
|
|
107
|
-
* The
|
|
143
|
+
* The filing policy, or undefined when no tracker is set.
|
|
108
144
|
*
|
|
109
145
|
* Keyed off the resolved `tracker` rather than provider precedence: reading
|
|
110
146
|
* whichever provider block happened to appear first could hand a GitHub label
|
|
@@ -117,9 +153,9 @@ const LisaBlockDirectIssueCreate = async () => {
|
|
|
117
153
|
* Resolved once per session at plugin init. That is a deliberate snapshot: a
|
|
118
154
|
* config edit mid-session needs a session restart to take effect, which is
|
|
119
155
|
* the same lifetime as the rest of this plugin's state.
|
|
120
|
-
* @returns The
|
|
156
|
+
* @returns The filing policy, or undefined.
|
|
121
157
|
*/
|
|
122
|
-
const
|
|
158
|
+
const resolvePolicy = async (): Promise<FilingPolicy | undefined> => {
|
|
123
159
|
const base = await readConfig(".lisa.config.json");
|
|
124
160
|
const local = await readConfig(".lisa.config.local.json");
|
|
125
161
|
const tracker = local.tracker ?? base.tracker;
|
|
@@ -132,10 +168,81 @@ const LisaBlockDirectIssueCreate = async () => {
|
|
|
132
168
|
if (tracker === "linear") return config.linear?.workflow?.ready;
|
|
133
169
|
return undefined;
|
|
134
170
|
};
|
|
135
|
-
|
|
171
|
+
const org = local.github?.org ?? base.github?.org;
|
|
172
|
+
const name = local.github?.repo ?? base.github?.repo;
|
|
173
|
+
const hardening = { ...base.hardening, ...local.hardening };
|
|
174
|
+
return {
|
|
175
|
+
readyRole: pick(local) ?? pick(base) ?? DEFAULT_READY_ROLE,
|
|
176
|
+
ownRepo: org && name ? `${org}/${name}`.toLowerCase() : undefined,
|
|
177
|
+
upstreamRepo: (
|
|
178
|
+
hardening.upstreamRepo ?? DEFAULT_UPSTREAM_REPO
|
|
179
|
+
).toLowerCase(),
|
|
180
|
+
upstreamReadyRole: hardening.upstreamReadyRole ?? DEFAULT_READY_ROLE,
|
|
181
|
+
callerIsGithub: tracker === "github",
|
|
182
|
+
};
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const policy = await resolvePolicy();
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* A repository token reduced to a comparable `owner/name`.
|
|
189
|
+
*
|
|
190
|
+
* gh accepts `OWNER/REPO`, `HOST/OWNER/REPO`, and a full browser URL, and
|
|
191
|
+
* GitHub is case-insensitive about both halves — so comparing raw tokens
|
|
192
|
+
* would call one repository two different places depending on how it was
|
|
193
|
+
* typed.
|
|
194
|
+
* @param value The raw token.
|
|
195
|
+
* @returns A lowercased `owner/name`, or undefined when it names no repo.
|
|
196
|
+
*/
|
|
197
|
+
const normaliseRepo = (value: string): string | undefined => {
|
|
198
|
+
const text = value.replace(/\.git$/, "");
|
|
199
|
+
const parts = text.split("/").filter(part => part && !part.endsWith(":"));
|
|
200
|
+
if (parts.length < 2) return undefined;
|
|
201
|
+
return `${parts.at(-2)}/${parts.at(-1)}`.toLowerCase();
|
|
136
202
|
};
|
|
137
203
|
|
|
138
|
-
|
|
204
|
+
/**
|
|
205
|
+
* The repository this creation is addressed at, when it names one.
|
|
206
|
+
* @param declarable The command text up to a bare `--`.
|
|
207
|
+
* @returns A lowercased `owner/name`, or undefined when the calling project
|
|
208
|
+
* is the target — the common case, and today's behaviour.
|
|
209
|
+
*/
|
|
210
|
+
const targetRepository = (declarable: string): string | undefined => {
|
|
211
|
+
const flag = REPO_FLAG.exec(declarable);
|
|
212
|
+
if (flag?.[2]) return normaliseRepo(flag[2]);
|
|
213
|
+
const endpoint = ISSUES_ENDPOINT.exec(declarable);
|
|
214
|
+
if (endpoint) return normaliseRepo(`${endpoint[1]}/${endpoint[2]}`);
|
|
215
|
+
return undefined;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Which ready-role tokens satisfy a creation addressed at `target`.
|
|
220
|
+
*
|
|
221
|
+
* A declaration is demanded either way; this decides only WHOSE vocabulary
|
|
222
|
+
* it is written in. The last branch is the indeterminate case — a
|
|
223
|
+
* GitHub-tracked project declaring no `github.org`/`github.repo` cannot be
|
|
224
|
+
* compared against a target, so both roles are accepted rather than
|
|
225
|
+
* inventing a refusal, and no cross-repo target is reported: the cross-repo
|
|
226
|
+
* message would claim this project's role does not answer, which is false in
|
|
227
|
+
* exactly that branch.
|
|
228
|
+
* @param resolved The filing policy.
|
|
229
|
+
* @param target The addressed repository, or undefined.
|
|
230
|
+
* @returns The acceptable role tokens, and the target to name in a refusal.
|
|
231
|
+
*/
|
|
232
|
+
const rolesFor = (
|
|
233
|
+
resolved: FilingPolicy,
|
|
234
|
+
target: string | undefined
|
|
235
|
+
): Verdict => {
|
|
236
|
+
if (target === undefined || target === resolved.ownRepo)
|
|
237
|
+
return { roles: [resolved.readyRole], named: undefined };
|
|
238
|
+
const role =
|
|
239
|
+
target === resolved.upstreamRepo
|
|
240
|
+
? resolved.upstreamReadyRole
|
|
241
|
+
: DEFAULT_READY_ROLE;
|
|
242
|
+
if (resolved.ownRepo !== undefined || !resolved.callerIsGithub)
|
|
243
|
+
return { roles: [role], named: target };
|
|
244
|
+
return { roles: [resolved.readyRole, role], named: undefined };
|
|
245
|
+
};
|
|
139
246
|
|
|
140
247
|
return {
|
|
141
248
|
"tool.execute.before": async (
|
|
@@ -143,7 +250,7 @@ const LisaBlockDirectIssueCreate = async () => {
|
|
|
143
250
|
output: { args?: { command?: string } }
|
|
144
251
|
) => {
|
|
145
252
|
if (input.tool !== "bash") return;
|
|
146
|
-
if (
|
|
253
|
+
if (policy === undefined) return;
|
|
147
254
|
const command = String(output.args?.command ?? "");
|
|
148
255
|
if (!command) return;
|
|
149
256
|
if (/--help\b|\s-h(\s|$)/.test(command)) return;
|
|
@@ -168,13 +275,59 @@ const LisaBlockDirectIssueCreate = async () => {
|
|
|
168
275
|
// post-`--` flags outright; acli parses straight past them and creates
|
|
169
276
|
// the item with the flag silently unapplied (verified). Fails closed.
|
|
170
277
|
const declarable = command.split(/(?:^|\s)--(?:\s|$)/)[0] ?? command;
|
|
278
|
+
// WHICH repository's vocabulary answers is decided by where the create
|
|
279
|
+
// is addressed, not by whose config file is nearest. A filing aimed at
|
|
280
|
+
// another repository used to be judged against this project's role,
|
|
281
|
+
// which that repository does not carry — and on a JIRA or Linear caller
|
|
282
|
+
// the demanded token was a workflow STATE, so there was no satisfiable
|
|
283
|
+
// answer at all. The property is unchanged: a declaration is still
|
|
284
|
+
// required, wherever the item lands.
|
|
285
|
+
const { roles, named } = rolesFor(policy, targetRepository(declarable));
|
|
171
286
|
const declaresRole = [...declarable.matchAll(LABEL_FLAG)].some(match =>
|
|
172
287
|
(match[2] ?? "")
|
|
173
288
|
.split(",")
|
|
174
289
|
.map(part => part.trim())
|
|
175
|
-
.includes(
|
|
290
|
+
.some(candidate => roles.includes(candidate))
|
|
176
291
|
);
|
|
177
292
|
if (declaresRole || declarable.includes(HUMAN_GATE_MARKER)) return;
|
|
293
|
+
if (named !== undefined)
|
|
294
|
+
throw new Error(
|
|
295
|
+
[
|
|
296
|
+
`block-direct-issue-create: refusing ${signature.name} — this filing declares no readiness.`,
|
|
297
|
+
"",
|
|
298
|
+
"WHY: a work item filed without the build-ready role is an incomplete",
|
|
299
|
+
"handoff. Build-intake scans the ready lane and nothing else, so nothing",
|
|
300
|
+
"will ever pick it up: the write succeeds and the work still dies.",
|
|
301
|
+
"",
|
|
302
|
+
`THIS FILING IS ADDRESSED AT ANOTHER REPOSITORY: ${named}.`,
|
|
303
|
+
"That repository runs its own build queue off its own ready role, so this",
|
|
304
|
+
"project's role does not answer for it — and this project's filing flow",
|
|
305
|
+
"writes to this project's tracker, so it cannot reach the target at all.",
|
|
306
|
+
"",
|
|
307
|
+
"FILE IT THE SANCTIONED WAY:",
|
|
308
|
+
"",
|
|
309
|
+
"1. An upstream defect or hardening report. Use the upstream filing path,",
|
|
310
|
+
" which composes a redacted, public-safe body through an allowlist",
|
|
311
|
+
" projection instead of free-form prose:",
|
|
312
|
+
"",
|
|
313
|
+
" bunx @codyswann/lisa file-upstream --input <filing-event>.json",
|
|
314
|
+
"",
|
|
315
|
+
" lisa-persist-learning step 6 runs exactly this, headless, on a cron.",
|
|
316
|
+
"2. If you must run the CLI directly, the command has to carry the TARGET",
|
|
317
|
+
` repository's build-ready role — ${roles.join(", ")} — as the value of a`,
|
|
318
|
+
" --label flag. Configure it as hardening.upstreamReadyRole when the",
|
|
319
|
+
" target renamed its lane.",
|
|
320
|
+
"",
|
|
321
|
+
`DO NOT reach for ${HUMAN_GATE_MARKER} to get past this one. It still`,
|
|
322
|
+
"satisfies the guard, but on an upstream defect report it is a false",
|
|
323
|
+
"declaration: the target's build queue scans the ready role and nothing",
|
|
324
|
+
"else, so the report is filed and never picked up.",
|
|
325
|
+
"",
|
|
326
|
+
"OPERATOR ESCAPE: a human can export LISA_ALLOW_DIRECT_ISSUE_CREATE=1 in",
|
|
327
|
+
"the environment before starting the session. Setting it inline on this",
|
|
328
|
+
"command is deliberately refused.",
|
|
329
|
+
].join("\n")
|
|
330
|
+
);
|
|
178
331
|
throw new Error(
|
|
179
332
|
[
|
|
180
333
|
`block-direct-issue-create: refusing ${signature.name} — this filing declares no readiness.`,
|
|
@@ -197,7 +350,7 @@ const LisaBlockDirectIssueCreate = async () => {
|
|
|
197
350
|
"the ready-role-filing rule for the full contract.",
|
|
198
351
|
"",
|
|
199
352
|
"If you must run the CLI directly, the command has to carry one of the",
|
|
200
|
-
`two declarations itself: the configured build-ready role "${readyRole}",`,
|
|
353
|
+
`two declarations itself: the configured build-ready role "${policy.readyRole}",`,
|
|
201
354
|
`or a ${HUMAN_GATE_MARKER} marker in the body it submits.`,
|
|
202
355
|
"",
|
|
203
356
|
"OPERATOR ESCAPE: a human can export LISA_ALLOW_DIRECT_ISSUE_CREATE=1 in",
|
|
@@ -52,8 +52,25 @@ export const BASELINE_LABEL = "bdd-floor-baseline";
|
|
|
52
52
|
* on it decides what this gate executes. The gate reads a base revision to
|
|
53
53
|
* decide whether a pull request may merge, which makes that a real
|
|
54
54
|
* substitution risk and not a theoretical one.
|
|
55
|
+
*
|
|
56
|
+
* Order within that constraint is set by measurement, not by convention. On
|
|
57
|
+
* macOS `/usr/bin/git` is not git: it is Apple's `xcrun` shim, and dispatching
|
|
58
|
+
* through it costs a **median 13,853 ms per invocation against 23-31 ms** for
|
|
59
|
+
* a real binary — randomized call order, fixed inter-call gaps, n=12 each
|
|
60
|
+
* (lisa#2887). `git --version` through the shim, doing no work at all, reached
|
|
61
|
+
* 33,699 ms. This gate makes two or more git calls per run, so the shim alone
|
|
62
|
+
* can spend a minute of a macOS run resolving a binary.
|
|
63
|
+
*
|
|
64
|
+
* The two entries promoted ahead of it are the developer-directory gits the
|
|
65
|
+
* shim itself dispatches to. Both are `root:wheel` files in system locations,
|
|
66
|
+
* so this is the same trust class as `/usr/bin/git` and not a relaxation: the
|
|
67
|
+
* user-writable `/usr/local` and Homebrew entries stay behind it, exactly
|
|
68
|
+
* where they already were. Neither promoted path exists on Linux or Windows,
|
|
69
|
+
* so every non-macOS runner resolves precisely what it resolved before.
|
|
55
70
|
*/
|
|
56
71
|
const GIT_CANDIDATES = Object.freeze([
|
|
72
|
+
"/Library/Developer/CommandLineTools/usr/bin/git",
|
|
73
|
+
"/Applications/Xcode.app/Contents/Developer/usr/bin/git",
|
|
57
74
|
"/usr/bin/git",
|
|
58
75
|
"/usr/local/bin/git",
|
|
59
76
|
"/opt/homebrew/bin/git",
|
package/package.json
CHANGED
|
@@ -141,7 +141,7 @@
|
|
|
141
141
|
"zod-validation-error": "^4.0.0"
|
|
142
142
|
},
|
|
143
143
|
"name": "@codyswann/lisa",
|
|
144
|
-
"version": "3.51.
|
|
144
|
+
"version": "3.51.3",
|
|
145
145
|
"description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
|
|
146
146
|
"main": "dist/index.js",
|
|
147
147
|
"exports": {
|
|
@@ -123,6 +123,7 @@ fi
|
|
|
123
123
|
# The build-ready role is read from config, never hard-coded: a project that
|
|
124
124
|
# renamed its ready lane must still be able to satisfy the guard, and the
|
|
125
125
|
# refusal has to name the token that project actually uses.
|
|
126
|
+
default_ready_role="status:ready"
|
|
126
127
|
case "$tracker" in
|
|
127
128
|
github) ready_role="$(read_config_value '.github.labels.build.ready')" ;;
|
|
128
129
|
jira) ready_role="$(read_config_value '.jira.workflow.ready')" ;;
|
|
@@ -130,7 +131,63 @@ case "$tracker" in
|
|
|
130
131
|
*) ready_role="" ;;
|
|
131
132
|
esac
|
|
132
133
|
if [ -z "$ready_role" ]; then
|
|
133
|
-
ready_role="
|
|
134
|
+
ready_role="$default_ready_role"
|
|
135
|
+
fi
|
|
136
|
+
|
|
137
|
+
# WHICH REPOSITORY'S VOCABULARY ANSWERS FOR THIS FILING
|
|
138
|
+
#
|
|
139
|
+
# The role above is the CALLING project's. For a same-repo filing that is the
|
|
140
|
+
# right question. For a filing addressed at a DIFFERENT repository — which
|
|
141
|
+
# Lisa ships a first-class, cron-driven path for in `lisa-persist-learning` —
|
|
142
|
+
# it is the wrong repository's vocabulary, and the guard demanded a token the
|
|
143
|
+
# target does not carry:
|
|
144
|
+
#
|
|
145
|
+
# - a JIRA or Linear caller's ready role is a workflow STATE. Demanded as a
|
|
146
|
+
# `gh --label` on another repo it is unsatisfiable, because that label does
|
|
147
|
+
# not exist there and `gh` rejects an unknown one. Obeying the guard made
|
|
148
|
+
# the command fail, which is not the same thing as being refused.
|
|
149
|
+
# - a GitHub caller that renamed its ready lane demanded its own token of a
|
|
150
|
+
# repository that never had it.
|
|
151
|
+
# - a GitHub caller on the stock lane worked only because both repositories
|
|
152
|
+
# happened to choose the same string. That is a coincidence, not routing.
|
|
153
|
+
#
|
|
154
|
+
# The one escape that IS satisfiable cross-repo, `[lisa-human-gate]`, is a lie
|
|
155
|
+
# about the item: it stamps a build-ready defect report as held for a human
|
|
156
|
+
# product call, and the target's build queue scans the ready role and nothing
|
|
157
|
+
# else. The report is filed and never picked up — precisely the incomplete
|
|
158
|
+
# handoff this guard exists to prevent, committed one repository over.
|
|
159
|
+
#
|
|
160
|
+
# So the target's own role answers, resolved from CONFIG rather than the
|
|
161
|
+
# network. A live `gh api repos/<o>/<r>/labels` lookup would be more general
|
|
162
|
+
# and is the wrong trade for a PreToolUse hook: a network round-trip on every
|
|
163
|
+
# intercepted command, and a new fail-open surface when it errors.
|
|
164
|
+
own_org="$(read_config_value '.github.org')"
|
|
165
|
+
own_name="$(read_config_value '.github.repo')"
|
|
166
|
+
own_repo=""
|
|
167
|
+
if [ -n "$own_org" ] && [ -n "$own_name" ]; then
|
|
168
|
+
own_repo="$own_org/$own_name"
|
|
169
|
+
fi
|
|
170
|
+
|
|
171
|
+
# `hardening.upstreamRepo` already names the upstream repository across Lisa's
|
|
172
|
+
# filing skills; `hardening.upstreamReadyRole` is its sibling, so the guard
|
|
173
|
+
# keeps its existing discipline — read the role from config, never hard-code it
|
|
174
|
+
# — while reading it from the RIGHT repository's config.
|
|
175
|
+
upstream_repo="$(read_config_value '.hardening.upstreamRepo')"
|
|
176
|
+
if [ -z "$upstream_repo" ]; then
|
|
177
|
+
upstream_repo="CodySwannGT/lisa"
|
|
178
|
+
fi
|
|
179
|
+
upstream_ready_role="$(read_config_value '.hardening.upstreamReadyRole')"
|
|
180
|
+
if [ -z "$upstream_ready_role" ]; then
|
|
181
|
+
upstream_ready_role="$default_ready_role"
|
|
182
|
+
fi
|
|
183
|
+
|
|
184
|
+
# A non-GitHub caller's ready role is a workflow STATE, so it is never the right
|
|
185
|
+
# vocabulary for a GitHub target no matter what the target turns out to be. The
|
|
186
|
+
# classifier needs to know that even when this project declares no repo of its
|
|
187
|
+
# own to compare against.
|
|
188
|
+
caller_is_github="0"
|
|
189
|
+
if [ "$tracker" = "github" ]; then
|
|
190
|
+
caller_is_github="1"
|
|
134
191
|
fi
|
|
135
192
|
|
|
136
193
|
# Ambient-only override. Deliberately read here, from the hook process's own
|
|
@@ -139,6 +196,11 @@ ambient_override="${LISA_ALLOW_DIRECT_ISSUE_CREATE:-}"
|
|
|
139
196
|
|
|
140
197
|
refuse() {
|
|
141
198
|
local signature="$1"
|
|
199
|
+
local roles="$2"
|
|
200
|
+
local target="$3"
|
|
201
|
+
if [ -n "$target" ]; then
|
|
202
|
+
refuse_cross_repo "$signature" "$roles" "$target"
|
|
203
|
+
fi
|
|
142
204
|
cat >&2 <<EOF
|
|
143
205
|
BLOCKED: refusing \`$signature\` — this filing declares no readiness.
|
|
144
206
|
|
|
@@ -184,6 +246,61 @@ EOF
|
|
|
184
246
|
exit 2
|
|
185
247
|
}
|
|
186
248
|
|
|
249
|
+
# The cross-repo refusal is a separate message, not a variable swapped into the
|
|
250
|
+
# one above, because its REMEDIATION is different. The local filing flow writes
|
|
251
|
+
# to this project's own tracker and structurally cannot reach another
|
|
252
|
+
# repository, so naming it here would send the agent to a path that cannot do
|
|
253
|
+
# the thing it was just refused for. Name the route that reaches the target,
|
|
254
|
+
# and name the target's role rather than this project's.
|
|
255
|
+
refuse_cross_repo() {
|
|
256
|
+
local signature="$1"
|
|
257
|
+
local roles="$2"
|
|
258
|
+
local target="$3"
|
|
259
|
+
cat >&2 <<EOF
|
|
260
|
+
BLOCKED: refusing \`$signature\` — this filing declares no readiness.
|
|
261
|
+
|
|
262
|
+
WHY: a work item filed without the build-ready role is an incomplete handoff.
|
|
263
|
+
Build-intake scans the ready lane and nothing else, so nothing will ever pick
|
|
264
|
+
it up: the write succeeds and the work still dies.
|
|
265
|
+
|
|
266
|
+
THIS FILING IS ADDRESSED AT ANOTHER REPOSITORY: \`$target\`.
|
|
267
|
+
That repository runs its own build queue off its own ready role, so this
|
|
268
|
+
project's role does not answer for it — and this project's filing flow writes
|
|
269
|
+
to this project's tracker, so it cannot reach the target at all.
|
|
270
|
+
|
|
271
|
+
FILE IT THE SANCTIONED WAY:
|
|
272
|
+
|
|
273
|
+
1. An upstream defect or hardening report — the highest-signal report there is,
|
|
274
|
+
because it is reproduced and attributed rather than guessed at. Use the
|
|
275
|
+
upstream filing path, which composes a redacted, public-safe body through an
|
|
276
|
+
allowlist projection instead of free-form prose:
|
|
277
|
+
|
|
278
|
+
bunx @codyswann/lisa file-upstream --input <filing-event>.json
|
|
279
|
+
|
|
280
|
+
\`lisa-persist-learning\` step 6 runs exactly this, headless, on a cron, and
|
|
281
|
+
files the result with explicit \`build_ready: true\` so the target's queue
|
|
282
|
+
picks it up.
|
|
283
|
+
|
|
284
|
+
2. If you must run the CLI directly, the command has to carry the TARGET
|
|
285
|
+
repository's build-ready role — \`$roles\` — as the value of a \`--label\`
|
|
286
|
+
flag. Configure it as \`hardening.upstreamReadyRole\` when the target renamed
|
|
287
|
+
its lane.
|
|
288
|
+
|
|
289
|
+
DO NOT reach for \`[lisa-human-gate]\` to get past this one. It still satisfies
|
|
290
|
+
the guard — it is a real declaration — but on an upstream defect report it is a
|
|
291
|
+
false one: it stamps the item as held for a human product call, and the
|
|
292
|
+
target's build queue scans the ready role and nothing else. The report is filed
|
|
293
|
+
and never picked up, which is the incomplete handoff this guard exists to
|
|
294
|
+
prevent, committed one repository over. Use it only when a human product call
|
|
295
|
+
is genuinely pending on the target.
|
|
296
|
+
|
|
297
|
+
OPERATOR ESCAPE: a human can export \`LISA_ALLOW_DIRECT_ISSUE_CREATE=1\` in the
|
|
298
|
+
environment before starting the session. It is deliberately not reachable by
|
|
299
|
+
setting it inline on this command — an inline assignment is refused.
|
|
300
|
+
EOF
|
|
301
|
+
exit 2
|
|
302
|
+
}
|
|
303
|
+
|
|
187
304
|
# The classifier is read into a variable with a top-level here-document rather
|
|
188
305
|
# than piped straight in from inside `$( … )`. bash 3.2 — which is what macOS
|
|
189
306
|
# still ships as /bin/bash, and therefore what this fleet's hooks run under —
|
|
@@ -202,6 +319,11 @@ import sys
|
|
|
202
319
|
command = os.environ.get("LISA_GUARD_COMMAND", "")
|
|
203
320
|
ready_role = os.environ.get("LISA_GUARD_READY_ROLE", "")
|
|
204
321
|
ambient_override = os.environ.get("LISA_GUARD_AMBIENT_OVERRIDE", "")
|
|
322
|
+
default_ready_role = os.environ.get("LISA_GUARD_DEFAULT_READY_ROLE", "")
|
|
323
|
+
own_repo = os.environ.get("LISA_GUARD_OWN_REPO", "").strip().lower()
|
|
324
|
+
upstream_repo = os.environ.get("LISA_GUARD_UPSTREAM_REPO", "").strip().lower()
|
|
325
|
+
upstream_ready_role = os.environ.get("LISA_GUARD_UPSTREAM_READY_ROLE", "")
|
|
326
|
+
caller_is_github = os.environ.get("LISA_GUARD_CALLER_IS_GITHUB", "") == "1"
|
|
205
327
|
|
|
206
328
|
OVERRIDE_NAME = "LISA_ALLOW_DIRECT_ISSUE_CREATE"
|
|
207
329
|
HUMAN_GATE_MARKER = "[lisa-human-gate]"
|
|
@@ -269,6 +391,14 @@ PAYLOAD_VALUE_FLAGS = {"-f", "-F", "--raw-field", "--field"}
|
|
|
269
391
|
|
|
270
392
|
GITHUB_ISSUES_PATH = re.compile(r"repos/[^/\s]+/[^/\s]+/issues/?$")
|
|
271
393
|
GITHUB_ISSUES_URL = re.compile(r"api\.github\.com/repos/[^/\s]+/[^/\s]+/issues")
|
|
394
|
+
# The repository a creation is ADDRESSED at, which decides whose ready role
|
|
395
|
+
# answers for it. `gh` accepts the flag before or after the subcommand and in
|
|
396
|
+
# either spelling, and the REST paths carry the same pair positionally.
|
|
397
|
+
REPO_FLAGS = {"--repo", "-R"}
|
|
398
|
+
GITHUB_ISSUES_PATH_REPO = re.compile(r"repos/([^/\s]+)/([^/\s]+)/issues/?$")
|
|
399
|
+
GITHUB_ISSUES_URL_REPO = re.compile(
|
|
400
|
+
r"api\.github\.com/repos/([^/\s]+)/([^/\s]+)/issues"
|
|
401
|
+
)
|
|
272
402
|
JIRA_ISSUE_URL = re.compile(r"atlassian\.net/rest/api/[^/\s]+/issue")
|
|
273
403
|
GRAPHQL_CREATE = re.compile(r"createIssue|issueCreate")
|
|
274
404
|
|
|
@@ -655,20 +785,118 @@ def body_file_paths(args):
|
|
|
655
785
|
return paths
|
|
656
786
|
|
|
657
787
|
|
|
658
|
-
def
|
|
788
|
+
def normalise_repo(value):
|
|
789
|
+
"""A `--repo` value reduced to a comparable `owner/name`.
|
|
790
|
+
|
|
791
|
+
`gh` accepts `OWNER/REPO`, `HOST/OWNER/REPO`, and a full browser URL, and
|
|
792
|
+
GitHub itself is case-insensitive about both halves — so comparing the raw
|
|
793
|
+
token would call the same repository two different places depending on how
|
|
794
|
+
it was typed.
|
|
795
|
+
|
|
796
|
+
Args:
|
|
797
|
+
value: The raw token.
|
|
798
|
+
|
|
799
|
+
Returns:
|
|
800
|
+
A lowercased `owner/name`, or None when the token names no repository.
|
|
801
|
+
"""
|
|
802
|
+
text = value.strip().strip("'\"")
|
|
803
|
+
if text.endswith(".git"):
|
|
804
|
+
text = text[: -len(".git")]
|
|
805
|
+
parts = [part for part in text.split("/") if part and not part.endswith(":")]
|
|
806
|
+
if len(parts) < 2:
|
|
807
|
+
return None
|
|
808
|
+
return ("%s/%s" % (parts[-2], parts[-1])).lower()
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def target_repository(args):
|
|
812
|
+
"""The repository this creation is addressed at, when it names one.
|
|
813
|
+
|
|
814
|
+
Read only from positions that actually reach the created item: a flag
|
|
815
|
+
before the end-of-options marker, or the endpoint the write is posted to.
|
|
816
|
+
A `-f repo=o/r` payload field is data being SENT, not the address being
|
|
817
|
+
posted to, and `endpoint_tokens` already excludes it.
|
|
818
|
+
|
|
819
|
+
Args:
|
|
820
|
+
args: A creating command's arguments.
|
|
821
|
+
|
|
822
|
+
Returns:
|
|
823
|
+
A lowercased `owner/name`, or None when the calling project is the
|
|
824
|
+
target — which is the overwhelmingly common case and today's behaviour.
|
|
825
|
+
"""
|
|
826
|
+
scoped = before_end_of_options(args)
|
|
827
|
+
for index, token in enumerate(scoped):
|
|
828
|
+
if token in REPO_FLAGS and index + 1 < len(scoped):
|
|
829
|
+
return normalise_repo(scoped[index + 1])
|
|
830
|
+
if "=" in token:
|
|
831
|
+
head, value = token.split("=", 1)
|
|
832
|
+
if head in REPO_FLAGS:
|
|
833
|
+
return normalise_repo(value)
|
|
834
|
+
for token in endpoint_tokens(scoped, GITHUB_ISSUES_PATH):
|
|
835
|
+
match = GITHUB_ISSUES_PATH_REPO.search(token)
|
|
836
|
+
if match:
|
|
837
|
+
return normalise_repo("%s/%s" % (match.group(1), match.group(2)))
|
|
838
|
+
for token in scoped:
|
|
839
|
+
match = GITHUB_ISSUES_URL_REPO.search(token)
|
|
840
|
+
if match:
|
|
841
|
+
return normalise_repo("%s/%s" % (match.group(1), match.group(2)))
|
|
842
|
+
return None
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def roles_for(target):
|
|
846
|
+
"""Which ready-role tokens satisfy a creation addressed at `target`.
|
|
847
|
+
|
|
848
|
+
The guard demands a declaration either way; this decides only WHOSE
|
|
849
|
+
vocabulary the declaration is written in.
|
|
850
|
+
|
|
851
|
+
The indeterminate case is the last branch: a GitHub-tracked project that
|
|
852
|
+
declares no `github.org`/`github.repo` cannot be compared against a target,
|
|
853
|
+
so both roles are accepted rather than inventing a refusal. That is
|
|
854
|
+
permissive about which token, never about whether one is required.
|
|
855
|
+
|
|
856
|
+
Args:
|
|
857
|
+
target: The addressed repository, or None.
|
|
858
|
+
|
|
859
|
+
Returns:
|
|
860
|
+
A (roles, cross_repo_target) pair. The target is None when the calling
|
|
861
|
+
project is the one being written to.
|
|
862
|
+
"""
|
|
863
|
+
if target is None or (own_repo and target == own_repo):
|
|
864
|
+
return [ready_role], None
|
|
865
|
+
if upstream_repo and target == upstream_repo:
|
|
866
|
+
role = upstream_ready_role
|
|
867
|
+
else:
|
|
868
|
+
# Another repository Lisa has no configuration for. Its lane is
|
|
869
|
+
# whatever GitHub's stock one is; the caller's token is categorically
|
|
870
|
+
# not it.
|
|
871
|
+
role = default_ready_role
|
|
872
|
+
if own_repo or not caller_is_github:
|
|
873
|
+
return [role], target
|
|
874
|
+
# Indeterminate, and the target is deliberately NOT reported. The refusal
|
|
875
|
+
# would otherwise say "this filing is addressed at another repository" and
|
|
876
|
+
# "this project's role does not answer for it" — the first unproven and the
|
|
877
|
+
# second flatly false, since this branch accepts the project's role. A
|
|
878
|
+
# message naming a token that does not work is the remediation pointing
|
|
879
|
+
# away from the fix, which is the defect being repaired here.
|
|
880
|
+
return [ready_role, role], None
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def declares_readiness(raw_args, roles):
|
|
659
884
|
"""Whether the create carries one of the two required declarations.
|
|
660
885
|
|
|
661
886
|
Args:
|
|
662
887
|
raw_args: The creating command's arguments.
|
|
888
|
+
roles: The build-ready role tokens that satisfy this filing.
|
|
663
889
|
|
|
664
890
|
Returns:
|
|
665
|
-
True when
|
|
891
|
+
True when a build-ready role or a human-gate marker is present.
|
|
666
892
|
"""
|
|
667
893
|
args = before_end_of_options(raw_args)
|
|
668
|
-
|
|
894
|
+
for role in roles:
|
|
895
|
+
if not role:
|
|
896
|
+
continue
|
|
669
897
|
for raw in flag_values(args, LABEL_FLAGS):
|
|
670
898
|
candidates = [part.strip().strip("'\"") for part in raw.split(",")]
|
|
671
|
-
if
|
|
899
|
+
if role in candidates:
|
|
672
900
|
return True
|
|
673
901
|
# The human-gate marker is matched anywhere, and that asymmetry is
|
|
674
902
|
# deliberate: it is a marker with no other meaning, so its presence in the
|
|
@@ -746,7 +974,8 @@ def scan(text, depth):
|
|
|
746
974
|
depth: Current nesting depth.
|
|
747
975
|
|
|
748
976
|
Returns:
|
|
749
|
-
A
|
|
977
|
+
A (signature, roles, cross_repo_target) triple, or None when nothing
|
|
978
|
+
creation-shaped was found.
|
|
750
979
|
"""
|
|
751
980
|
try:
|
|
752
981
|
stripped = strip_heredocs(text)
|
|
@@ -757,7 +986,11 @@ def scan(text, depth):
|
|
|
757
986
|
# raises on the unbalanced quote. Two appended characters, no binary
|
|
758
987
|
# required. "I could not parse it" must never mean "it is fine".
|
|
759
988
|
if UNPARSEABLE_CREATION.search(text):
|
|
760
|
-
return
|
|
989
|
+
return (
|
|
990
|
+
"an unparseable command that reads as a tracker creation",
|
|
991
|
+
[ready_role],
|
|
992
|
+
None,
|
|
993
|
+
)
|
|
761
994
|
return None
|
|
762
995
|
|
|
763
996
|
for argv in segment(tokens):
|
|
@@ -774,9 +1007,10 @@ def scan(text, depth):
|
|
|
774
1007
|
# disqualifies the override rather than supplying it.
|
|
775
1008
|
if ambient_override and not inline_override:
|
|
776
1009
|
continue
|
|
777
|
-
|
|
1010
|
+
roles, target = roles_for(target_repository(args))
|
|
1011
|
+
if declares_readiness(args, roles):
|
|
778
1012
|
continue
|
|
779
|
-
return signature
|
|
1013
|
+
return signature, roles, target
|
|
780
1014
|
|
|
781
1015
|
for operand in nested_operands(argv):
|
|
782
1016
|
if depth >= MAX_NESTING_DEPTH:
|
|
@@ -784,7 +1018,11 @@ def scan(text, depth):
|
|
|
784
1018
|
# made a creation inside a 4th `bash -c` layer pass, which is
|
|
785
1019
|
# the depth cap being used as the bypass.
|
|
786
1020
|
if UNPARSEABLE_CREATION.search(operand):
|
|
787
|
-
return
|
|
1021
|
+
return (
|
|
1022
|
+
"a tracker creation nested past the inspection depth",
|
|
1023
|
+
[ready_role],
|
|
1024
|
+
None,
|
|
1025
|
+
)
|
|
788
1026
|
continue
|
|
789
1027
|
nested = scan(operand, depth + 1)
|
|
790
1028
|
if nested is not None:
|
|
@@ -802,7 +1040,14 @@ inline_override = (OVERRIDE_NAME + "=") in command
|
|
|
802
1040
|
|
|
803
1041
|
found = scan(command, 0)
|
|
804
1042
|
if found is not None:
|
|
805
|
-
|
|
1043
|
+
signature, roles, target = found
|
|
1044
|
+
# Key=value lines rather than one delimited string: a signature contains
|
|
1045
|
+
# spaces and slashes, and a role may contain a colon, so anything the shell
|
|
1046
|
+
# would have to split on appears inside a value already.
|
|
1047
|
+
print("REFUSE")
|
|
1048
|
+
print("signature=%s" % signature)
|
|
1049
|
+
print("roles=%s" % ", ".join(role for role in roles if role))
|
|
1050
|
+
print("target=%s" % (target or ""))
|
|
806
1051
|
sys.exit(0)
|
|
807
1052
|
|
|
808
1053
|
print("ALLOW")
|
|
@@ -815,6 +1060,11 @@ verdict="$(
|
|
|
815
1060
|
LISA_GUARD_COMMAND="$command_str" \
|
|
816
1061
|
LISA_GUARD_READY_ROLE="$ready_role" \
|
|
817
1062
|
LISA_GUARD_AMBIENT_OVERRIDE="$ambient_override" \
|
|
1063
|
+
LISA_GUARD_DEFAULT_READY_ROLE="$default_ready_role" \
|
|
1064
|
+
LISA_GUARD_OWN_REPO="$own_repo" \
|
|
1065
|
+
LISA_GUARD_UPSTREAM_REPO="$upstream_repo" \
|
|
1066
|
+
LISA_GUARD_UPSTREAM_READY_ROLE="$upstream_ready_role" \
|
|
1067
|
+
LISA_GUARD_CALLER_IS_GITHUB="$caller_is_github" \
|
|
818
1068
|
python3 -
|
|
819
1069
|
)"
|
|
820
1070
|
python_status=$?
|
|
@@ -827,8 +1077,17 @@ if [ "$python_status" -ne 0 ]; then
|
|
|
827
1077
|
exit 0
|
|
828
1078
|
fi
|
|
829
1079
|
|
|
1080
|
+
verdict_field() {
|
|
1081
|
+
printf '%s\n' "$verdict" | sed -n "s/^$1=//p" | head -1
|
|
1082
|
+
}
|
|
1083
|
+
|
|
830
1084
|
case "$verdict" in
|
|
831
|
-
REFUSE*)
|
|
1085
|
+
REFUSE*)
|
|
1086
|
+
refuse \
|
|
1087
|
+
"$(verdict_field signature)" \
|
|
1088
|
+
"$(verdict_field roles)" \
|
|
1089
|
+
"$(verdict_field target)"
|
|
1090
|
+
;;
|
|
832
1091
|
esac
|
|
833
1092
|
|
|
834
1093
|
exit 0
|