feat: add LexAI status bar and suggestion panel
Some checks failed
CI — Test & Build / Test & Build (push) Has been cancelled
Some checks failed
CI — Test & Build / Test & Build (push) Has been cancelled
- Implemented a status bar item for LexAI with dynamic status updates (ready, processing, notReady). - Created a suggestion panel for displaying and interacting with AI-generated suggestions. - Added functionality for accepting, regenerating, and discarding suggestions within the suggestion zone. - Introduced configuration options for writing style, prompt patterns, personas, and formats. - Integrated progress indicators for long-running tasks and improved user feedback. - Established TypeScript configuration for the vscode package.
This commit is contained in:
31
.cursor/hooks/README.md
Normal file
31
.cursor/hooks/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Hooks
|
||||
|
||||
Two hooks, both small, both readable in a minute, both safe to delete. They exist because a few of this kit's rules are the kind a model reliably rationalizes past under momentum — and those are exactly the rules worth making deterministic.
|
||||
|
||||
| Hook | Event | What it does |
|
||||
| --- | --- | --- |
|
||||
| `session-context.mjs` | `sessionStart` | Injects the session's starting facts: whether the model lanes are bound (and whether the picker drifted from the recorded lead), the handoff's next action, and any of the four capped context files currently over its cap. |
|
||||
| `guard-destructive.mjs` | `beforeShellExecution` | Returns `ask` — never `deny` — for force pushes, `rm -rf`, migrations, deploys, infra changes, pipe-to-shell, and friends, with the reason named and the gate quoted back to the agent. Routine `git push` is deliberately not on the list. |
|
||||
|
||||
## Why these two
|
||||
|
||||
The lead-model check is the one that can only be done here. Cursor's model picker is a UI setting no project file can read or set, but the `sessionStart` payload carries the session's `model_id` — so this is the only place the recorded lane and the running model can actually be compared. Without it, a drifted picker shows up as a surprising invoice.
|
||||
|
||||
The cap check is deterministic arithmetic. A rule that says "keep `MEMORY.md` under 60 lines" is a request; counting the lines is an observation. Same for the shell gate: "get authorization before destructive actions" is advice, and `ask` is a stop.
|
||||
|
||||
## Safety properties
|
||||
|
||||
- **Fail-open by construction.** Neither hook sets `failClosed`, and both catch their own errors and exit 0. If Node is missing, if a file is malformed, if the script throws — Cursor logs it and the session continues. The worst case is losing the report, never losing the session.
|
||||
- **`ask`, not `deny`.** The shell guard can only insert a confirmation. It cannot block you out of your own repository, and it has no way to be silently stricter than you expect.
|
||||
- **Read-only.** Neither hook writes a file, phones home, or reads anything outside the workspace root Cursor hands it. `session-context.mjs` reads four project files (`docs/MODEL_ROUTING.md`, `docs/HANDOFF.md`, `docs/MEMORY.md`, `docs/TASKS.md`) plus `AGENTS.md` for the Lessons count; `guard-destructive.mjs` reads only the command string.
|
||||
- **No dependencies.** Plain Node ESM, no `node_modules`. `node --version` is the entire requirement, which is also why they are `.mjs` and invoked as `node .cursor/hooks/…` rather than shell scripts — that runs identically on Windows, macOS, and Linux.
|
||||
|
||||
## Editing them
|
||||
|
||||
The destructive-command list in `guard-destructive.mjs` is a starting point, not a policy. Add your project's real hazards (a `deploy.sh`, a data-export command, a billing CLI) and remove what does not apply — a gate you approve reflexively every time has stopped meaning anything and should go. Routine `git push` was cut from the default list for exactly that reason; add it back if pushing is genuinely consequential in your repo.
|
||||
|
||||
Cursor runs project hooks from the project root, so paths in `hooks.json` are written `.cursor/hooks/…` rather than `./hooks/…`.
|
||||
|
||||
## Removing them
|
||||
|
||||
Delete `.cursor/hooks.json` and this directory. Nothing else in the kit depends on them — the rules they enforce are still written in `AGENTS.md`; they just go back to being advice.
|
||||
72
.cursor/hooks/guard-destructive.mjs
Normal file
72
.cursor/hooks/guard-destructive.mjs
Normal file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* beforeShellExecution hook — turn the kit's "explicit authorization before
|
||||
* destructive or external action" rule into something that actually stops.
|
||||
*
|
||||
* A rule in a prompt is advice the model can rationalize past under momentum.
|
||||
* This is a gate. It never denies on its own — it returns "ask", so you decide,
|
||||
* with the reason named. That keeps the failure mode "one extra confirmation"
|
||||
* rather than "the agent cannot work".
|
||||
*
|
||||
* Contract: stdin is the beforeShellExecution payload; stdout is
|
||||
* {"permission": "allow"|"ask"|"deny", "user_message": "...", "agent_message": "..."}.
|
||||
* Exit 0 = success. This hook has no failClosed flag in hooks.json, so if node is
|
||||
* missing or this script throws, Cursor fails open and the session keeps working.
|
||||
*/
|
||||
|
||||
// Routine `git push` is deliberately NOT gated: a prompt that fires on every push gets
|
||||
// approved reflexively and stops meaning anything. Force pushes are. Add your own.
|
||||
const PATTERNS = [
|
||||
[/\brm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s+)+/, "recursive or forced delete"],
|
||||
[/\bgit\s+push\b.*(--force|-f)\b/, "force push"],
|
||||
[/\bgit\s+(reset\s+--hard|clean\s+-[a-zA-Z]*f)/, "discards uncommitted work"],
|
||||
[/\b(drop|truncate)\s+(table|database|schema)\b/i, "destructive database statement"],
|
||||
[/\b(migrate|db:migrate|alembic\s+upgrade|prisma\s+migrate\s+deploy)\b/i, "database migration"],
|
||||
[/\b(terraform|pulumi)\s+(apply|destroy)\b/, "infrastructure change"],
|
||||
[/\bkubectl\s+(delete|apply)\b/, "cluster change"],
|
||||
[/\b(npm|pnpm|yarn)\s+publish\b/, "package publish"],
|
||||
[/\b(vercel|netlify|fly|heroku|wrangler)\s+(deploy|publish)\b/i, "deployment"],
|
||||
[/\bdocker\s+(push|system\s+prune)\b/, "registry push or prune"],
|
||||
[/\bchmod\s+(-R\s+)?777\b/, "world-writable permissions"],
|
||||
[/\bcurl\b[^|]*\|\s*(ba)?sh\b/, "pipe-to-shell from the network"],
|
||||
[/>\s*\/dev\/sd[a-z]|\bmkfs\b|\bdd\s+if=.*of=\/dev\//, "raw device write"],
|
||||
];
|
||||
|
||||
const readAll = () =>
|
||||
new Promise((resolve) => {
|
||||
let data = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (c) => (data += c));
|
||||
process.stdin.on("end", () => resolve(data));
|
||||
setTimeout(() => resolve(data), 2000).unref();
|
||||
});
|
||||
|
||||
const allow = () => {
|
||||
process.stdout.write(JSON.stringify({ permission: "allow" }));
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
const raw = await readAll();
|
||||
let command = "";
|
||||
try {
|
||||
command = JSON.parse(raw || "{}").command || "";
|
||||
} catch {
|
||||
allow();
|
||||
}
|
||||
|
||||
const hit = PATTERNS.find(([re]) => re.test(command));
|
||||
if (!hit) allow();
|
||||
|
||||
const reason = hit[1];
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
permission: "ask",
|
||||
user_message: `Gated: ${reason}. Approve only if you intended this.`,
|
||||
agent_message:
|
||||
`This command was gated as a ${reason}. Per the quality gates in AGENTS.md, destructive, external, ` +
|
||||
`and irreversible actions need explicit owner authorization — loop momentum is not authorization. ` +
|
||||
`If the owner declines, record it as a decision-ready item in docs/PROGRESS.md and re-route to another ` +
|
||||
`independent unit rather than looking for a way around this command.`,
|
||||
}),
|
||||
);
|
||||
process.exit(0);
|
||||
148
.cursor/hooks/session-context.mjs
Normal file
148
.cursor/hooks/session-context.mjs
Normal file
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* sessionStart hook — inject the facts a session should never have to ask for.
|
||||
*
|
||||
* 1. Are the model lanes bound, and is the picker running the model we recorded?
|
||||
* 2. What did the last session leave as the next action?
|
||||
* 3. Is any capped context file over its cap right now?
|
||||
*
|
||||
* All three are deterministic file/state checks. The point of doing them here rather
|
||||
* than in a prompt is that a rule asking the model to "check the caps" is a request;
|
||||
* this is an observation.
|
||||
*
|
||||
* Contract: stdin is the sessionStart JSON payload; stdout is
|
||||
* {"additional_context": "..."}. This hook is fire-and-forget — Cursor logs the
|
||||
* response but never blocks session creation on it. It fails open by design:
|
||||
* any error prints an empty object and exits 0.
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const readAll = () =>
|
||||
new Promise((resolve) => {
|
||||
let data = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (c) => (data += c));
|
||||
process.stdin.on("end", () => resolve(data));
|
||||
setTimeout(() => resolve(data), 2000).unref();
|
||||
});
|
||||
|
||||
const read = (p) => {
|
||||
try {
|
||||
return existsSync(p) ? readFileSync(p, "utf8") : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Real content lines: not blank, not a heading, not the file's instructional blockquote,
|
||||
* not a table rule, not an HTML comment, and not one of the shipped `_None yet._` /
|
||||
* `[placeholder]` template rows. Counting boilerplate would report a freshly installed
|
||||
* kit as already consuming its caps.
|
||||
*/
|
||||
const isPlaceholder = (l) =>
|
||||
/^[-|*\s]*_?(none|no )/i.test(l) ||
|
||||
/^\|?\s*_?\[/.test(l) ||
|
||||
/^-\s*\*\(/.test(l) ||
|
||||
/^\|\s*\[/.test(l);
|
||||
|
||||
const contentLines = (text) =>
|
||||
text
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(
|
||||
(l) =>
|
||||
l &&
|
||||
!l.startsWith("#") &&
|
||||
!l.startsWith(">") &&
|
||||
!l.startsWith("<!--") &&
|
||||
!l.startsWith("*") &&
|
||||
!/^\|?[\s|:-]+\|?$/.test(l) &&
|
||||
!isPlaceholder(l),
|
||||
);
|
||||
|
||||
function main(payload) {
|
||||
const root = payload?.workspace_roots?.[0] ?? process.cwd();
|
||||
const notes = [];
|
||||
|
||||
// ---- 1. model routing -------------------------------------------------
|
||||
const routing = read(join(root, "docs", "MODEL_ROUTING.md"));
|
||||
if (!routing) {
|
||||
notes.push("Routing: docs/MODEL_ROUTING.md is missing — this kit is not fully installed.");
|
||||
} else if (/\[LEAD\]|\[STRONG\]|\[MID\]|\[FAST\]/.test(routing)) {
|
||||
notes.push(
|
||||
"Routing: UNBOUND. Every subagent is still `model: inherit`, so each one costs what this session costs. " +
|
||||
"Run /model-routing before delegating.",
|
||||
);
|
||||
} else {
|
||||
const row = routing.match(/^\|\s*\*\*lead\*\*\s*\|\s*`?([^`|]+?)`?\s*\|/m);
|
||||
// Exact match after normalising case and separators. Substring matching would treat
|
||||
// composer-2.5 and composer-2.5-fast — two different lanes in this kit's own table —
|
||||
// as the same model and stay silent on a real downgrade.
|
||||
const norm = (s) => s.toLowerCase().replace(/[\s_()]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
||||
const recorded = row?.[1]?.trim();
|
||||
const actual = (payload?.model_id || payload?.model || "").trim();
|
||||
if (recorded && actual && norm(recorded) !== norm(actual)) {
|
||||
notes.push(
|
||||
`Routing: lead-model drift — docs/MODEL_ROUTING.md records "${recorded}" but this session is running "${actual}". ` +
|
||||
"Either switch the picker or re-run /model-routing; do not silently proceed on the wrong lane.",
|
||||
);
|
||||
} else if (recorded) {
|
||||
notes.push(`Routing: bound (lead = ${recorded}).`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 2. handoff -------------------------------------------------------
|
||||
const handoff = read(join(root, "docs", "HANDOFF.md"));
|
||||
if (handoff) {
|
||||
const next = handoff.match(/Next smallest action:\*{0,2}\s*(.+)/);
|
||||
if (next) notes.push(`Handoff next action: ${next[1].trim()}`);
|
||||
}
|
||||
|
||||
// ---- 3. context caps --------------------------------------------------
|
||||
const over = [];
|
||||
const memory = read(join(root, "docs", "MEMORY.md"));
|
||||
if (memory) {
|
||||
const n = contentLines(memory).filter((l) => l.startsWith("-") || l.startsWith("|")).length;
|
||||
if (n > 60) over.push(`docs/MEMORY.md ${n}/60 entry lines`);
|
||||
}
|
||||
if (handoff) {
|
||||
const n = contentLines(handoff).length;
|
||||
if (n > 25) over.push(`docs/HANDOFF.md ${n}/25 lines`);
|
||||
}
|
||||
const tasks = read(join(root, "docs", "TASKS.md"));
|
||||
if (tasks) {
|
||||
const active = tasks.split(/^##\s+/m).find((s) => /^Active\b/i.test(s));
|
||||
if (active) {
|
||||
const n = (active.match(/^###\s+T-/gm) || []).length;
|
||||
if (n > 7) over.push(`docs/TASKS.md Active ${n}/7 contracts`);
|
||||
}
|
||||
}
|
||||
const agents = read(join(root, "AGENTS.md"));
|
||||
if (agents) {
|
||||
const section = agents.split(/^##\s+Lessons\s*$/m)[1];
|
||||
if (section) {
|
||||
const rules = section.split(/^##\s/m)[0].split("\n").filter((l) => /^\s*-\s+\S/.test(l) && !l.includes("*(Add one-line"));
|
||||
if (rules.length > 12) over.push(`AGENTS.md ## Lessons ${rules.length}/12 rules`);
|
||||
}
|
||||
}
|
||||
if (over.length) {
|
||||
notes.push(`Context caps EXCEEDED: ${over.join(" · ")}. Run /memory-sync before adding anything.`);
|
||||
}
|
||||
|
||||
return notes.length
|
||||
? { additional_context: `Project state (from .cursor/hooks/session-context.mjs):\n- ${notes.join("\n- ")}` }
|
||||
: {};
|
||||
}
|
||||
|
||||
const raw = await readAll();
|
||||
let out = {};
|
||||
try {
|
||||
out = main(raw ? JSON.parse(raw) : {});
|
||||
} catch {
|
||||
out = {};
|
||||
}
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
process.exit(0);
|
||||
Reference in New Issue
Block a user