llm-cli-gateway 2.14.0 → 2.15.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.
@@ -0,0 +1,294 @@
1
+ ---
2
+ name: retrospective-walk
3
+ description: Walk a human or agent through a diff, worktree, commit range, gateway job, or episode reference as a structured retrospective. Use after implement-review-fix or multi-LLM review cycles, when reviewing prior jobs or uncommitted work, or when durable evidence is needed for what changed, why it changed, who/when contributed, and captured human or model comments.
4
+ ---
5
+
6
+ # Retrospective Walk
7
+
8
+ Guide a change review as a narrative with evidence. Prefer this when the user
9
+ needs the story of a change set, not just hunk-level inspection.
10
+
11
+ ## Inputs
12
+
13
+ Accept these fields when the user supplies them, and infer conservative
14
+ defaults when they do not:
15
+
16
+ ```json
17
+ {
18
+ "scope": "diff | commit-range | worktree | job-id | episode-ref",
19
+ "target": "diff text, HEAD~5..HEAD, path, job id, or episode reference",
20
+ "mode": "guided | agent-driven | summary-only",
21
+ "capture_comments": true,
22
+ "models_for_why": [],
23
+ "include_prior_evidence": true
24
+ }
25
+ ```
26
+
27
+ Default `mode` to `guided` for a human in the loop, `agent-driven` for automated
28
+ review, and `summary-only` only when explicitly requested or when interaction is
29
+ not possible. Default `capture_comments` to true.
30
+
31
+ ## Scope Resolution
32
+
33
+ Resolve the target into immutable evidence before analysis:
34
+
35
+ - `diff`: use the supplied diff text. If the target is empty, capture
36
+ `git diff --no-ext-diff` and `git diff --cached --no-ext-diff`.
37
+ - `commit-range`: capture `git diff --stat <range>`, `git diff --name-status
38
+ <range>`, and per-commit metadata from `git log --format=fuller <range>`.
39
+ - `worktree`: capture status, dirty file list, staged diff, and unstaged diff.
40
+ Do not stash, reset, or mutate the target worktree. If deeper model/tool
41
+ analysis may write files, create a disposable isolated worktree or analyze a
42
+ read-only evidence packet instead.
43
+ - `job-id`: fetch `llm_job_status` and `llm_job_result`; include `cli`,
44
+ `correlationId`, `sessionId`, timestamps, exit status, and output digests.
45
+ - `episode-ref`: resolve through the caller's available episode/DAG context. If
46
+ no episode tool is available, record the unresolved reference and continue
47
+ from any linked diff, job, receipt, or review artifacts the user supplied.
48
+
49
+ If `include_prior_evidence` is true, collect linked validation runs, receipts,
50
+ review reports, approval records, job ids, and session/cache state that are
51
+ available through gateway tools or local artifacts. Treat summaries as claims;
52
+ prefer raw diffs, job outputs, receipt ids, commands, and file:line evidence.
53
+
54
+ ## Change Units
55
+
56
+ Group hunks and files into logical change units before narrating them. Favor
57
+ semantic intent over file boundaries, but keep units small enough to review.
58
+
59
+ Use these grouping signals:
60
+
61
+ - Shared feature, bug, invariant, or user-facing behavior.
62
+ - Production code plus its tests and docs.
63
+ - Config/schema/migration changes that must be deployed together.
64
+ - Review-only or evidence-only changes, such as verification reports.
65
+
66
+ Assign stable ids: `cu_001`, `cu_002`, etc. For each unit, record:
67
+
68
+ ```json
69
+ {
70
+ "change_unit_id": "cu_001",
71
+ "title": "Short noun phrase",
72
+ "files": ["src/example.ts"],
73
+ "diff_refs": ["commit abc123", "hunk @@ -42,7 +42,9 @@"],
74
+ "what": "Semantic summary of the delta",
75
+ "why": "Intent, rationale, alternatives, and unresolved assumptions",
76
+ "who_when": [
77
+ {
78
+ "actor_type": "human | model | agent | git-author | unknown",
79
+ "identifier": "name, model key, job id, or commit author",
80
+ "timestamp": "RFC3339 if known",
81
+ "provenance": "commit, job, session, receipt, or user statement"
82
+ }
83
+ ],
84
+ "evidence": ["file:line", "job id", "validation receipt id", "command digest"],
85
+ "comments": []
86
+ }
87
+ ```
88
+
89
+ ## What, Why, Who
90
+
91
+ For each change unit:
92
+
93
+ 1. Extract **what** directly from the diff, file reads, tests, and docs.
94
+ 2. Derive **why** from commit messages, issue/DAG context, review findings,
95
+ prior receipts, job prompts/results, and nearby code intent.
96
+ 3. Attribute **who / when** using git author/committer metadata, gateway job
97
+ metadata, session ids, model keys, review outputs, and explicit user
98
+ statements.
99
+ 4. Mark inference boundaries. Use `inferred:` when intent or authorship is
100
+ reasoned from evidence rather than directly stated.
101
+ 5. Surface missing evidence as an open question instead of filling the gap with
102
+ confidence.
103
+
104
+ Use `multi-llm-review` or direct gateway model calls only when why-synthesis is
105
+ ambiguous, high stakes, or explicitly requested. Omit `model` unless the user
106
+ requested specific variants; use `approvalStrategy:"mcp_managed"` and async job
107
+ handling from `async-job-orchestration` for longer analysis. Ask models for
108
+ intent/rationale synthesis, not for unverified approval.
109
+
110
+ ## Comment Capture
111
+
112
+ When `capture_comments` is true, comments are evidence. Capture them at the
113
+ change-unit level and, when possible, at the file/hunk/line location.
114
+
115
+ In `guided` mode, walk one unit at a time:
116
+
117
+ 1. Present the unit's what/why/who and evidence.
118
+ 2. Ask for comments, concerns, approvals, suggestions, or intent corrections.
119
+ 3. Normalize each answer into the schema below.
120
+ 4. Continue only after comments are captured or the user skips the unit.
121
+
122
+ In `agent-driven` mode, create agent/model comments only when they add evidence,
123
+ concern, approval, or intent clarification beyond the narrative. In
124
+ `summary-only` mode, do not stop for comments; include an empty comments array
125
+ unless comments were supplied up front.
126
+
127
+ Comment schema:
128
+
129
+ ```json
130
+ {
131
+ "comment_id": "cmt_<ulid>",
132
+ "retrospective_id": "retro_<ulid>",
133
+ "change_unit_id": "cu_001",
134
+ "author": {
135
+ "author_type": "human | model",
136
+ "identifier": "werner or claude-4-opus",
137
+ "model_key": "optional; only for model comments"
138
+ },
139
+ "timestamp": "RFC3339",
140
+ "type": "note | concern | approval | suggestion | intent_clarification",
141
+ "text": "Comment body",
142
+ "location": {
143
+ "file": "optional path",
144
+ "hunk_range": "optional @@ range",
145
+ "semantic_unit": "optional semantic code-search node or future semantic id",
146
+ "line_start": 1,
147
+ "line_end": 2
148
+ },
149
+ "linked_receipts": ["validation receipt ids"],
150
+ "linked_jobs": ["async job ids"],
151
+ "metadata": {
152
+ "namespace.key": "freeform extension values"
153
+ }
154
+ }
155
+ ```
156
+
157
+ Generate ids in a stable, sortable form when the runtime has a ULID helper;
158
+ otherwise use `retro_<timestamp>_<short-hash>` and
159
+ `cmt_<timestamp>_<sequence>`.
160
+
161
+ ## Output Contract
162
+
163
+ Emit both a readable retrospective and a machine-readable block. Keep the
164
+ machine block fenced as `json` and make it complete enough to persist.
165
+
166
+ Markdown structure:
167
+
168
+ ```markdown
169
+ # Retrospective Walk: <target>
170
+
171
+ Retrospective ID: retro_<id>
172
+ Scope: <scope>
173
+ Mode: <mode>
174
+ Evidence baseline: <commands, commits, jobs, receipts>
175
+
176
+ ## Narrative
177
+ <overall story: what happened, why, and remaining uncertainty>
178
+
179
+ ## Change Units
180
+ ### cu_001 - <title>
181
+ What: ...
182
+ Why: ...
183
+ Who / when: ...
184
+ Evidence: ...
185
+ Comments: ...
186
+
187
+ ## Open Questions
188
+ - ...
189
+
190
+ ## Evidence Package
191
+ - validation_receipt: <id or unavailable: reason>
192
+ - linked_jobs: [...]
193
+ - linked_receipts: [...]
194
+ ```
195
+
196
+ Machine-readable shape:
197
+
198
+ ```json
199
+ {
200
+ "retrospective_id": "retro_<id>",
201
+ "schema_version": "retrospective-walk.v0.1.0",
202
+ "scope": "commit-range",
203
+ "target": "HEAD~5..HEAD",
204
+ "mode": "guided",
205
+ "created_at": "RFC3339",
206
+ "provenance_context": {
207
+ "commands": [],
208
+ "commits": [],
209
+ "jobs": [],
210
+ "receipts": [],
211
+ "episodes": []
212
+ },
213
+ "change_units": [],
214
+ "comments": [],
215
+ "narrative": "",
216
+ "open_questions": [],
217
+ "validation_receipt": {
218
+ "status": "minted | linked | unavailable",
219
+ "validation_id": "optional",
220
+ "receipt_id": "optional",
221
+ "reason": "optional"
222
+ }
223
+ }
224
+ ```
225
+
226
+ ## Validation Receipts
227
+
228
+ Use existing gateway validation receipt machinery when the retrospective is
229
+ backed by a terminal validation run:
230
+
231
+ - For prior validation ids, call `validation_receipt` and link the receipt.
232
+ - For review or why-synthesis model jobs launched during the retrospective,
233
+ preserve job ids and retrieve receipts if those jobs are part of a validation
234
+ run that can mint one.
235
+ - If no receipt-capable validation run exists, set
236
+ `validation_receipt.status` to `unavailable` and include a reason. Do not
237
+ claim a receipt was minted when the current gateway surface only produced a
238
+ retrospective artifact.
239
+
240
+ ## DAG Plan
241
+
242
+ Use this plan shape when embedding the retrospective in a DAG or episode:
243
+
244
+ ```toml
245
+ [plan]
246
+ name = "retrospective-walk"
247
+ version = "0.1.0"
248
+ description = "Guided retrospective over changes with what/why/who and comment capture as evidence"
249
+ owner = "verivus-oss"
250
+
251
+ [[node]]
252
+ id = "resolve-scope"
253
+ type = "data"
254
+ outputs = ["change_units", "provenance_context"]
255
+
256
+ [[node]]
257
+ id = "analyze-changes"
258
+ type = "compute"
259
+ inputs = ["change_units", "provenance_context", "models_for_why"]
260
+ outputs = ["analyzed_units"]
261
+
262
+ [[node]]
263
+ id = "capture-comments"
264
+ type = "human-or-agent"
265
+ inputs = ["analyzed_units"]
266
+ outputs = ["comments"]
267
+
268
+ [[node]]
269
+ id = "synthesize-retrospective"
270
+ type = "compute"
271
+ inputs = ["analyzed_units", "comments", "provenance_context"]
272
+ outputs = ["retrospective_markdown", "retrospective_json"]
273
+
274
+ [[node]]
275
+ id = "emit-evidence"
276
+ type = "side-effect"
277
+ inputs = ["retrospective_json", "comments", "analyzed_units"]
278
+ outputs = ["validation_receipt_id"]
279
+ ```
280
+
281
+ Only update a DAG or episode when the caller provided a parent context and an
282
+ available tool supports that write. Otherwise emit the retrospective and the
283
+ evidence package without side effects.
284
+
285
+ ## Security And Isolation
286
+
287
+ - Prefer read-only evidence capture. Do not mutate user worktrees while walking
288
+ changes.
289
+ - Use a disposable isolated git worktree for model/tool operations that may edit
290
+ files, especially for `scope = worktree`.
291
+ - Keep secrets, local account names, and machine-specific paths out of
292
+ persisted comments and receipts unless the user explicitly requires them.
293
+ - Treat model-generated comments as model evidence, not human approval.
294
+ - Preserve provenance for every comment, job, receipt, and inferred rationale.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,27 @@ All notable changes to the llm-cli-gateway project.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.15.0] - 2026-07-03
8
+
9
+ ### Added
10
+
11
+ - **Updateable local skill packs.** Gateway startup now loads bundled skills,
12
+ optional `[skills].paths`, `LLM_GATEWAY_SKILLS_PATH`, and
13
+ `~/.llm-cli-gateway/skills` with deterministic override precedence. Skill
14
+ pack roots can include a `skill-pack.json` integrity manifest that pins each
15
+ loaded `SKILL.md` by SHA-256, allowing operators to update workflow skills
16
+ outside core gateway npm releases without silent network fetches.
17
+
18
+ ## [2.14.1] - 2026-07-03
19
+
20
+ ### Added
21
+
22
+ - **`retrospective-walk` workflow skill.** Bundles a guided retrospective skill
23
+ for walking a human or agent through a diff, worktree, commit range, gateway
24
+ job, or episode reference with structured what/why/who analysis, comment
25
+ capture as evidence, machine-readable output, and validation-receipt links
26
+ when an existing receipt-capable validation run is available.
27
+
7
28
  ## [2.14.0] - 2026-07-03
8
29
 
9
30
  ### Fixed
package/README.md CHANGED
@@ -51,7 +51,41 @@ Or use directly with `npx` from an MCP client:
51
51
 
52
52
  ## Workflow Assets
53
53
 
54
- The repo ships agent-ready workflow skills under [`.agents/skills`](.agents/skills) for async orchestration, session continuity, multi-LLM review, implement-review-fix loops, and secure approval-gated dispatch. Six caller-facing skills are bundled in the published npm package: `async-job-orchestration`, `multi-llm-review`, `session-workflow`, `secure-orchestration`, `implement-review-fix`, and `public-demo-session`. Machine-readable DAG-TOML plans live under [`docs/plans`](docs/plans) and [`setup/install-plan.dag.toml`](setup/install-plan.dag.toml) for workflows that need deterministic sequencing and verification gates.
54
+ The repo ships agent-ready workflow skills under [`.agents/skills`](.agents/skills) for async orchestration, session continuity, multi-LLM review, implement-review-fix loops, retrospective evidence walks, and secure approval-gated dispatch. Seven caller-facing skills are bundled in the published npm package: `async-job-orchestration`, `multi-llm-review`, `session-workflow`, `secure-orchestration`, `implement-review-fix`, `retrospective-walk`, and `public-demo-session`. Machine-readable DAG-TOML plans live under [`docs/plans`](docs/plans) and [`setup/install-plan.dag.toml`](setup/install-plan.dag.toml) for workflows that need deterministic sequencing and verification gates.
55
+
56
+ Skill packs can be updated outside the core npm release by placing skill
57
+ directories in local, operator-controlled paths. The gateway loads bundled
58
+ skills first, then `[skills].paths`, then `LLM_GATEWAY_SKILLS_PATH`, then
59
+ `~/.llm-cli-gateway/skills` when it exists; later roots override earlier skills
60
+ by name. Each skill is a directory containing `SKILL.md`. A root may also carry
61
+ `skill-pack.json` to pin expected `SKILL.md` hashes:
62
+
63
+ ```toml
64
+ [skills]
65
+ paths = ["/opt/llm-cli-gateway/skills"]
66
+ ```
67
+
68
+ ```bash
69
+ export LLM_GATEWAY_SKILLS_PATH="/opt/team-skill-pack:/opt/incident-skill-pack"
70
+ ```
71
+
72
+ ```json
73
+ {
74
+ "name": "team-pack",
75
+ "version": "1.0.0",
76
+ "skills": [
77
+ {
78
+ "name": "incident-retrospective",
79
+ "sha256": "<sha256 of incident-retrospective/SKILL.md>"
80
+ }
81
+ ]
82
+ }
83
+ ```
84
+
85
+ The loader is intentionally local-only: it never fetches remote Markdown at
86
+ startup. To update a pack, install or replace files through your package manager
87
+ or deployment system, then restart the gateway so the advertised `skills://...`
88
+ resources refresh.
55
89
 
56
90
  The next documentation focus is provider-specific skill and DAG-TOML pairs for each outbound CLI and API-provider family: Claude, Codex, Gemini/Antigravity, Grok, Mistral Vibe, Devin, Cursor Agent, OpenAI-compatible endpoints, Anthropic Messages, and xAI Responses. The implementation plan is tracked in [`docs/plans/provider-workflow-assets.dag.toml`](docs/plans/provider-workflow-assets.dag.toml), with each provider asset expected to cover install/login checks or token-env checks, session behavior, approval modes, cache/telemetry surfaces, failure modes, and a smoke-test gate.
57
91
 
@@ -1435,6 +1469,7 @@ await callTool("session_delete", {
1435
1469
  LLM_GATEWAY_OAUTH_REQUIRE_CONSENT=1 LLM_GATEWAY_OAUTH_CONSENT_SECRET='choose-a-strong-code' node dist/index.js
1436
1470
  ```
1437
1471
  - `LLM_GATEWAY_CONFIG`: Path to the gateway TOML config (default: `~/.llm-cli-gateway/config.toml`). See **Persistence configuration** above for the `[persistence]` schema.
1472
+ - `LLM_GATEWAY_SKILLS_PATH`: Extra local skill-pack roots to load at startup, separated by the host path delimiter (`:` on Linux/macOS, `;` on Windows). These paths are appended after `[skills].paths`; `~/.llm-cli-gateway/skills` still loads last when present.
1438
1473
  - `LLM_GATEWAY_LOGS_DB`: **Deprecated** — overrides `[persistence].path` and selects `backend = "sqlite"` (or `backend = "none"` when set to `none`). Emits a deprecation warning at startup; migrate to `config.toml`.
1439
1474
  ```bash
1440
1475
  # Custom path
package/dist/config.d.ts CHANGED
@@ -15,6 +15,13 @@ export interface Config {
15
15
  database?: DatabaseConfig;
16
16
  sessionTtl: number;
17
17
  }
18
+ export interface SkillsConfig {
19
+ paths: string[];
20
+ sources: {
21
+ configFile: string | null;
22
+ envOverrides: string[];
23
+ };
24
+ }
18
25
  export declare function loadConfig(): Config;
19
26
  export declare const PERSISTENCE_BACKENDS: readonly ["sqlite", "postgres", "memory", "none"];
20
27
  export type PersistenceBackend = (typeof PERSISTENCE_BACKENDS)[number];
@@ -46,6 +53,7 @@ export interface PersistenceConfigSources {
46
53
  envOverrides: string[];
47
54
  }
48
55
  export declare function defaultGatewayConfigPath(): string;
56
+ export declare function loadSkillsConfig(logger?: Logger): SkillsConfig;
49
57
  export declare function loadPersistenceConfig(logger?: Logger): PersistenceConfig;
50
58
  export declare const DEFAULT_HTTP_MAX_SESSIONS = 100;
51
59
  export declare const DEFAULT_HTTP_SESSION_IDLE_TTL_MS: number;
package/dist/config.js CHANGED
@@ -14,6 +14,12 @@ const DatabaseUrlSchema = z
14
14
  message: "Database URL must start with postgresql:// or postgres://",
15
15
  });
16
16
  export const DEFAULT_SESSION_TTL_SECONDS = 2592000;
17
+ const SkillsSchema = z
18
+ .object({
19
+ paths: z.array(z.string().min(1)).default([]),
20
+ })
21
+ .strict()
22
+ .default({ paths: [] });
17
23
  export function loadConfig() {
18
24
  const databaseUrl = process.env.DATABASE_URL;
19
25
  const rawSessionTtl = parseInt(process.env.SESSION_TTL || String(DEFAULT_SESSION_TTL_SECONDS), 10);
@@ -89,6 +95,32 @@ function defaultPersistenceConfigPath() {
89
95
  export function defaultGatewayConfigPath() {
90
96
  return defaultPersistenceConfigPath();
91
97
  }
98
+ export function loadSkillsConfig(logger = noopLogger) {
99
+ const configPath = defaultGatewayConfigPath();
100
+ const { parsed, sourcePath } = readGatewayTomlFile(configPath, logger, "skills");
101
+ const rawSkills = parsed?.skills ?? {};
102
+ const sources = {
103
+ configFile: sourcePath,
104
+ envOverrides: [],
105
+ };
106
+ let skillsParsed;
107
+ try {
108
+ skillsParsed = SkillsSchema.parse(rawSkills);
109
+ }
110
+ catch (err) {
111
+ throw new Error(`Invalid [skills] config: ${err instanceof Error ? err.message : String(err)}`);
112
+ }
113
+ const paths = [...skillsParsed.paths];
114
+ const envPaths = process.env.LLM_GATEWAY_SKILLS_PATH;
115
+ if (envPaths !== undefined && envPaths.trim().length > 0) {
116
+ paths.push(...envPaths
117
+ .split(path.delimiter)
118
+ .map(part => part.trim())
119
+ .filter(Boolean));
120
+ sources.envOverrides.push("LLM_GATEWAY_SKILLS_PATH");
121
+ }
122
+ return { paths, sources };
123
+ }
92
124
  function readPersistenceFile(configPath, logger) {
93
125
  if (!existsSync(configPath)) {
94
126
  return { raw: undefined, sourcePath: null };
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mc
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { randomUUID } from "crypto";
5
5
  import { createRequire } from "module";
6
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync, chmodSync, } from "fs";
6
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, chmodSync, } from "fs";
7
7
  import { basename, dirname, isAbsolute, join, relative } from "path";
8
8
  import { fileURLToPath } from "url";
9
9
  import { z } from "zod/v3";
@@ -21,7 +21,8 @@ import { ResourceProvider } from "./resources.js";
21
21
  import { generateResourceDescriptors, generateProviderAcpDescriptors, } from "./provider-surface-generator.js";
22
22
  import { PerformanceMetrics } from "./metrics.js";
23
23
  import { estimateTokens, optimizePrompt as optimizePromptText, optimizeResponse as optimizeResponseText, } from "./optimizer.js";
24
- import { loadConfig, loadPersistenceConfig, loadCacheAwarenessConfig, loadProvidersConfig, loadAcpConfig, loadAdminConfig, loadLimitsConfig, defaultGatewayConfigPath, isXaiProviderEnabled, enabledApiProviders, minStableTokensForModel, } from "./config.js";
24
+ import { loadConfig, loadPersistenceConfig, loadCacheAwarenessConfig, loadProvidersConfig, loadAcpConfig, loadAdminConfig, loadLimitsConfig, loadSkillsConfig, defaultGatewayConfigPath, isXaiProviderEnabled, enabledApiProviders, minStableTokensForModel, } from "./config.js";
25
+ import { loadGatewaySkills } from "./skill-loader.js";
25
26
  import { runAcpRequest } from "./acp/runtime.js";
26
27
  import { isAcpError } from "./acp/errors.js";
27
28
  import { redactSecrets } from "./secret-redaction.js";
@@ -184,27 +185,13 @@ function packageVersion() {
184
185
  }
185
186
  return "unknown";
186
187
  }
187
- function loadSkills() {
188
- const skills = [];
189
- try {
190
- const dirs = readdirSync(SKILLS_DIR, { withFileTypes: true }).filter(d => d.isDirectory());
191
- for (const dir of dirs) {
192
- const skillPath = join(SKILLS_DIR, dir.name, "SKILL.md");
193
- try {
194
- const content = readFileSync(skillPath, "utf-8");
195
- const descMatch = content.match(/^---[\s\S]*?description:\s*(.+?)$/m);
196
- const description = descMatch?.[1]?.trim() || dir.name;
197
- skills.push({ name: dir.name, content, description });
198
- }
199
- catch {
200
- }
201
- }
202
- }
203
- catch {
204
- }
205
- return skills;
206
- }
207
- const loadedSkills = loadSkills();
188
+ const skillsConfig = loadSkillsConfig(logger);
189
+ const loadedSkills = loadGatewaySkills({
190
+ bundledSkillsDir: SKILLS_DIR,
191
+ configuredPaths: skillsConfig.paths,
192
+ envSkillsPath: undefined,
193
+ logger,
194
+ });
208
195
  export function buildServerInstructions(asyncJobsEnabled, grokApiToolsEnabled = false) {
209
196
  const asyncToolsNote = asyncJobsEnabled ? " | *_request_async (async)" : "";
210
197
  const apiToolsNote = grokApiToolsEnabled ? ", grok_api_request" : "";
@@ -0,0 +1,24 @@
1
+ import type { Logger } from "./logger.js";
2
+ export interface SkillEntry {
3
+ name: string;
4
+ content: string;
5
+ description: string;
6
+ source: "bundled" | "external";
7
+ path: string;
8
+ pack: {
9
+ name: string;
10
+ version: string;
11
+ manifestPath: string;
12
+ verified: boolean;
13
+ } | null;
14
+ }
15
+ export interface LoadGatewaySkillsOptions {
16
+ bundledSkillsDir: string;
17
+ configuredPaths?: string[];
18
+ envSkillsPath?: string | undefined;
19
+ userSkillsDir?: string;
20
+ logger?: Logger;
21
+ }
22
+ export declare function defaultUserSkillsDir(): string;
23
+ export declare function parseSkillPathList(raw: string | undefined): string[];
24
+ export declare function loadGatewaySkills(options: LoadGatewaySkillsOptions): SkillEntry[];
@@ -0,0 +1,201 @@
1
+ import { createHash } from "crypto";
2
+ import { existsSync, readFileSync, readdirSync, statSync } from "fs";
3
+ import { homedir } from "os";
4
+ import path from "path";
5
+ import { z } from "zod/v3";
6
+ import { logWarn, noopLogger } from "./logger.js";
7
+ const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}$/;
8
+ const SkillPackManifestSchema = z
9
+ .object({
10
+ name: z.string().regex(SKILL_NAME_PATTERN),
11
+ version: z.string().min(1),
12
+ skills: z
13
+ .array(z
14
+ .object({
15
+ name: z.string().regex(SKILL_NAME_PATTERN),
16
+ sha256: z.string().regex(/^[a-f0-9]{64}$/i),
17
+ })
18
+ .strict())
19
+ .default([]),
20
+ })
21
+ .strict();
22
+ export function defaultUserSkillsDir() {
23
+ return path.join(homedir(), ".llm-cli-gateway", "skills");
24
+ }
25
+ export function parseSkillPathList(raw) {
26
+ if (!raw)
27
+ return [];
28
+ return raw
29
+ .split(path.delimiter)
30
+ .map(part => part.trim())
31
+ .filter(Boolean);
32
+ }
33
+ export function loadGatewaySkills(options) {
34
+ const logger = options.logger ?? noopLogger;
35
+ const roots = [
36
+ { path: options.bundledSkillsDir, source: "bundled", label: "bundled" },
37
+ ...normalizeExternalRoots(options.configuredPaths ?? [], "configured"),
38
+ ...normalizeExternalRoots(parseSkillPathList(options.envSkillsPath), "LLM_GATEWAY_SKILLS_PATH"),
39
+ ];
40
+ const userSkillsDir = options.userSkillsDir ?? defaultUserSkillsDir();
41
+ if (existsSync(userSkillsDir)) {
42
+ roots.push({ path: userSkillsDir, source: "external", label: "user" });
43
+ }
44
+ const byName = new Map();
45
+ for (const root of roots) {
46
+ for (const skill of readSkillRoot(root, logger)) {
47
+ const prior = byName.get(skill.name);
48
+ if (prior) {
49
+ logWarn(logger, `Skill '${skill.name}' from ${skill.path} overrides ${prior.source} skill at ${prior.path}`);
50
+ }
51
+ byName.set(skill.name, skill);
52
+ }
53
+ }
54
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
55
+ }
56
+ function normalizeExternalRoots(paths, label) {
57
+ return paths.map(p => ({
58
+ path: expandHome(p),
59
+ source: "external",
60
+ label,
61
+ }));
62
+ }
63
+ function expandHome(p) {
64
+ return p === "~" ? homedir() : p.startsWith("~/") ? path.join(homedir(), p.slice(2)) : p;
65
+ }
66
+ function readSkillRoot(root, logger) {
67
+ if (!existsSync(root.path)) {
68
+ if (root.source === "external") {
69
+ logWarn(logger, `Configured skill path does not exist; skipping: ${root.path}`);
70
+ }
71
+ return [];
72
+ }
73
+ let stat;
74
+ try {
75
+ stat = statSync(root.path);
76
+ }
77
+ catch (err) {
78
+ logWarn(logger, `Cannot stat skill path; skipping: ${root.path}`, err);
79
+ return [];
80
+ }
81
+ if (!stat.isDirectory()) {
82
+ logWarn(logger, `Skill path is not a directory; skipping: ${root.path}`);
83
+ return [];
84
+ }
85
+ const manifest = readManifest(root, logger);
86
+ if (manifest === "invalid")
87
+ return [];
88
+ if (existsSync(path.join(root.path, "SKILL.md"))) {
89
+ const skill = readSkillDir(root.path, root, manifest, logger);
90
+ return skill ? [skill] : [];
91
+ }
92
+ let entries;
93
+ try {
94
+ entries = readdirSync(root.path, { withFileTypes: true });
95
+ }
96
+ catch (err) {
97
+ logWarn(logger, `Cannot read skill path; skipping: ${root.path}`, err);
98
+ return [];
99
+ }
100
+ const skills = [];
101
+ for (const entry of entries) {
102
+ if (!entry.isDirectory())
103
+ continue;
104
+ const skill = readSkillDir(path.join(root.path, entry.name), root, manifest, logger);
105
+ if (skill)
106
+ skills.push(skill);
107
+ }
108
+ if (manifest && manifest.skills.length > 0) {
109
+ const loaded = new Set(skills.map(skill => skill.name));
110
+ for (const expected of manifest.skills) {
111
+ if (!loaded.has(expected.name)) {
112
+ logWarn(logger, `Skill pack '${manifest.name}' declares '${expected.name}', but no verified SKILL.md was loaded`);
113
+ }
114
+ }
115
+ }
116
+ return skills.sort((a, b) => a.name.localeCompare(b.name));
117
+ }
118
+ function readManifest(root, logger) {
119
+ const manifestPath = path.join(root.path, "skill-pack.json");
120
+ if (!existsSync(manifestPath))
121
+ return null;
122
+ try {
123
+ const raw = JSON.parse(readFileSync(manifestPath, "utf8"));
124
+ return SkillPackManifestSchema.parse(raw);
125
+ }
126
+ catch (err) {
127
+ logWarn(logger, `Invalid skill-pack.json at ${manifestPath}; skipping skill pack`, err);
128
+ return "invalid";
129
+ }
130
+ }
131
+ function readSkillDir(skillDir, root, manifest, logger) {
132
+ const skillPath = path.join(skillDir, "SKILL.md");
133
+ if (!existsSync(skillPath))
134
+ return null;
135
+ let content;
136
+ try {
137
+ content = readFileSync(skillPath, "utf8");
138
+ }
139
+ catch (err) {
140
+ logWarn(logger, `Cannot read skill file; skipping: ${skillPath}`, err);
141
+ return null;
142
+ }
143
+ const frontmatter = parseSkillFrontmatter(content);
144
+ const dirName = path.basename(skillDir);
145
+ const name = frontmatter.name || dirName;
146
+ if (!SKILL_NAME_PATTERN.test(name)) {
147
+ logWarn(logger, `Invalid skill name '${name}' in ${skillPath}; skipping`);
148
+ return null;
149
+ }
150
+ if (frontmatter.name && frontmatter.name !== dirName) {
151
+ logWarn(logger, `Skill frontmatter name '${frontmatter.name}' does not match directory '${dirName}'; skipping ${skillPath}`);
152
+ return null;
153
+ }
154
+ const packInfo = verifyManifestEntry(name, content, skillPath, root, manifest, logger);
155
+ if (packInfo === "skip")
156
+ return null;
157
+ return {
158
+ name,
159
+ content,
160
+ description: frontmatter.description || name,
161
+ source: root.source,
162
+ path: skillPath,
163
+ pack: packInfo,
164
+ };
165
+ }
166
+ function verifyManifestEntry(name, content, skillPath, root, manifest, logger) {
167
+ if (!manifest)
168
+ return null;
169
+ const expected = manifest.skills.find(skill => skill.name === name);
170
+ if (!expected) {
171
+ logWarn(logger, `Skill pack '${manifest.name}' does not list '${name}' in skill-pack.json; skipping ${skillPath}`);
172
+ return "skip";
173
+ }
174
+ const actual = createHash("sha256").update(content, "utf8").digest("hex");
175
+ if (actual.toLowerCase() !== expected.sha256.toLowerCase()) {
176
+ logWarn(logger, `Skill pack '${manifest.name}' hash mismatch for '${name}'; skipping ${skillPath}`);
177
+ return "skip";
178
+ }
179
+ return {
180
+ name: manifest.name,
181
+ version: manifest.version,
182
+ manifestPath: path.join(root.path, "skill-pack.json"),
183
+ verified: true,
184
+ };
185
+ }
186
+ function parseSkillFrontmatter(content) {
187
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
188
+ if (!match)
189
+ return { name: null, description: null };
190
+ return {
191
+ name: scalarFrontmatterValue(match[1], "name"),
192
+ description: scalarFrontmatterValue(match[1], "description"),
193
+ };
194
+ }
195
+ function scalarFrontmatterValue(frontmatter, key) {
196
+ const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
197
+ const match = frontmatter.match(new RegExp(`^${escapedKey}:\\s*(.+?)\\s*$`, "m"));
198
+ if (!match)
199
+ return null;
200
+ return match[1].replace(/^["']|["']$/g, "").trim();
201
+ }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "llm-cli-gateway",
3
- "version": "2.14.0",
3
+ "version": "2.15.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "llm-cli-gateway",
9
- "version": "2.14.0",
9
+ "version": "2.15.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@modelcontextprotocol/sdk": "^1.29.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-cli-gateway",
3
- "version": "2.14.0",
3
+ "version": "2.15.0",
4
4
  "mcpName": "io.github.verivus-oss/llm-cli-gateway",
5
5
  "description": "Local-first MCP gateway for cross-model review across native coding-agent CLIs and API-token LLMs with sessions, durable jobs, and validation receipts.",
6
6
  "license": "MIT",
@@ -58,6 +58,7 @@
58
58
  ".agents/skills/session-workflow/SKILL.md",
59
59
  ".agents/skills/secure-orchestration/SKILL.md",
60
60
  ".agents/skills/implement-review-fix/SKILL.md",
61
+ ".agents/skills/retrospective-walk/SKILL.md",
61
62
  ".agents/skills/public-demo-session/SKILL.md"
62
63
  ],
63
64
  "scripts": {