loreli 0.0.0 → 1.0.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/LICENSE +1 -1
- package/README.md +670 -97
- package/bin/loreli.js +89 -0
- package/package.json +74 -14
- package/packages/README.md +101 -0
- package/packages/action/README.md +98 -0
- package/packages/action/src/index.js +656 -0
- package/packages/agent/README.md +517 -0
- package/packages/agent/src/backends/claude.js +287 -0
- package/packages/agent/src/backends/codex.js +278 -0
- package/packages/agent/src/backends/cursor.js +294 -0
- package/packages/agent/src/backends/index.js +329 -0
- package/packages/agent/src/base.js +138 -0
- package/packages/agent/src/cli.js +198 -0
- package/packages/agent/src/factory.js +119 -0
- package/packages/agent/src/index.js +12 -0
- package/packages/agent/src/models.js +141 -0
- package/packages/agent/src/output.js +62 -0
- package/packages/agent/src/session.js +162 -0
- package/packages/agent/src/trace.js +186 -0
- package/packages/config/README.md +833 -0
- package/packages/config/src/defaults.js +134 -0
- package/packages/config/src/index.js +192 -0
- package/packages/config/src/schema.js +273 -0
- package/packages/config/src/validate.js +160 -0
- package/packages/context/README.md +165 -0
- package/packages/context/src/index.js +198 -0
- package/packages/hub/README.md +338 -0
- package/packages/hub/src/base.js +154 -0
- package/packages/hub/src/github.js +1558 -0
- package/packages/hub/src/index.js +79 -0
- package/packages/hub/src/labels.js +48 -0
- package/packages/identity/README.md +288 -0
- package/packages/identity/src/index.js +620 -0
- package/packages/identity/src/themes/avatar.js +217 -0
- package/packages/identity/src/themes/digimon.js +217 -0
- package/packages/identity/src/themes/dragonball.js +217 -0
- package/packages/identity/src/themes/lotr.js +217 -0
- package/packages/identity/src/themes/marvel.js +217 -0
- package/packages/identity/src/themes/pokemon.js +217 -0
- package/packages/identity/src/themes/starwars.js +217 -0
- package/packages/identity/src/themes/transformers.js +217 -0
- package/packages/identity/src/themes/zelda.js +217 -0
- package/packages/knowledge/README.md +237 -0
- package/packages/knowledge/src/index.js +412 -0
- package/packages/log/README.md +93 -0
- package/packages/log/src/index.js +252 -0
- package/packages/marker/README.md +200 -0
- package/packages/marker/src/index.js +184 -0
- package/packages/mcp/README.md +279 -0
- package/packages/mcp/instructions.md +121 -0
- package/packages/mcp/scaffolding/.agents/skills/loreli-context/SKILL.md +89 -0
- package/packages/mcp/scaffolding/ISSUE_TEMPLATE/config.yml +2 -0
- package/packages/mcp/scaffolding/ISSUE_TEMPLATE/loreli.yml +83 -0
- package/packages/mcp/scaffolding/loreli.yml +453 -0
- package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +3 -0
- package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +11 -0
- package/packages/mcp/scaffolding/mcp-configs/.mcp.json +11 -0
- package/packages/mcp/scaffolding/pull-request.md +23 -0
- package/packages/mcp/src/index.js +571 -0
- package/packages/mcp/src/tools/agents.js +429 -0
- package/packages/mcp/src/tools/context.js +199 -0
- package/packages/mcp/src/tools/github.js +1199 -0
- package/packages/mcp/src/tools/hitl.js +149 -0
- package/packages/mcp/src/tools/index.js +17 -0
- package/packages/mcp/src/tools/start.js +835 -0
- package/packages/mcp/src/tools/status.js +146 -0
- package/packages/mcp/src/tools/work.js +124 -0
- package/packages/orchestrator/README.md +192 -0
- package/packages/orchestrator/src/index.js +1226 -0
- package/packages/planner/README.md +168 -0
- package/packages/planner/src/index.js +1166 -0
- package/packages/review/README.md +129 -0
- package/packages/review/src/index.js +1283 -0
- package/packages/risk/README.md +119 -0
- package/packages/risk/src/index.js +428 -0
- package/packages/session/README.md +165 -0
- package/packages/session/src/index.js +215 -0
- package/packages/test-utils/README.md +96 -0
- package/packages/test-utils/src/index.js +354 -0
- package/packages/tmux/README.md +261 -0
- package/packages/tmux/src/index.js +452 -0
- package/packages/workflow/README.md +313 -0
- package/packages/workflow/src/index.js +481 -0
- package/packages/workflow/src/proof-of-life.js +74 -0
- package/packages/workspace/README.md +143 -0
- package/packages/workspace/src/index.js +1076 -0
- package/index.js +0 -8
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# loreli/knowledge
|
|
2
|
+
|
|
3
|
+
Knowledge capture engine — the write path. Classifies review and plan feedback, detects recurring patterns across PRs and discussions, proposes promotions via GitHub Discussions with smart target suggestions, and applies approved promotions by creating action issues.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The knowledge package closes the feedback loop in Loreli's agentic workflow. Review comments and plan verdict feedback are classified into categories, aggregated across PRs and discussions, and when recurring patterns emerge, promoted into permanent standards — AGENTS.md, custom prompts, or other configuration.
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
classify → patterns → propose → [human approval] → apply
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Feedback is captured from two sources:
|
|
14
|
+
|
|
15
|
+
- **PR reviews** — `forward()` in `loreli/review` classifies `REQUEST_CHANGES` feedback and posts a `loreli:feedback` marker with `source="pr"` on the PR.
|
|
16
|
+
- **Plan verdicts** — The `plan/verdict` action classifies `changes-requested` feedback and posts a `loreli:feedback` marker with `source="plan"` on the discussion.
|
|
17
|
+
|
|
18
|
+
## API Reference
|
|
19
|
+
|
|
20
|
+
### `classify(feedback, opts?)`
|
|
21
|
+
|
|
22
|
+
Categorize feedback text into a category using keyword heuristics. Each category has a set of regex patterns (see [Configuration](#configuration) for the full list). The text is scored against every allowed category, and the highest-scoring category wins.
|
|
23
|
+
|
|
24
|
+
**Parameters:**
|
|
25
|
+
|
|
26
|
+
| Name | Type | Description |
|
|
27
|
+
|------|------|-------------|
|
|
28
|
+
| `feedback` | `string` | Review or verdict comment text. |
|
|
29
|
+
| `opts.categories` | `string[]` | Restrict classification to only these categories. When provided, categories not in this list are never scored — even if the text contains matching keywords. Defaults to all six built-in categories. |
|
|
30
|
+
|
|
31
|
+
**Returns:** `{ category: string, confidence: number }`
|
|
32
|
+
|
|
33
|
+
- `category` — The highest-scoring category from the allowed set. When no keywords match anything, defaults to `documentation`.
|
|
34
|
+
- `confidence` — `0` to `1`. Each keyword match adds `1/3` to confidence (capped at `1.0`). Zero means no keywords matched.
|
|
35
|
+
|
|
36
|
+
The following example classifies a review comment about naming conventions:
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
import { classify } from 'loreli/knowledge';
|
|
40
|
+
|
|
41
|
+
const { category, confidence } = classify(
|
|
42
|
+
'The naming convention should follow camelCase for consistency'
|
|
43
|
+
);
|
|
44
|
+
// category: 'naming', confidence: 0.67
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
When categories are restricted, matching keywords outside the allowed set are ignored. This is how the `feedback.categories` config restricts what gets captured during PR reviews:
|
|
48
|
+
|
|
49
|
+
```js
|
|
50
|
+
classify('Missing test coverage', { categories: ['naming', 'security'] });
|
|
51
|
+
// category: 'naming' (or 'security'), confidence: 0
|
|
52
|
+
// 'testing' keywords match but 'testing' is not in the allowed list
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### `patterns(hub, repo, opts?)`
|
|
56
|
+
|
|
57
|
+
Scan `loreli:feedback` markers across PR comments and discussion comments to detect recurring patterns above a configurable threshold.
|
|
58
|
+
|
|
59
|
+
**Parameters:**
|
|
60
|
+
|
|
61
|
+
| Name | Type | Description |
|
|
62
|
+
|------|------|-------------|
|
|
63
|
+
| `hub` | `object` | Hub instance with `searchIssues`, `comments`, `category`, `discussions`, and `discussionComments` methods. |
|
|
64
|
+
| `repo` | `string` | `"owner/name"` repository. |
|
|
65
|
+
| `opts.threshold` | `number` | Minimum occurrences to report a pattern. Default: `5`. |
|
|
66
|
+
| `opts.category` | `string` | Filter to a specific category. |
|
|
67
|
+
|
|
68
|
+
**Returns:** `Promise<Array<Pattern>>`
|
|
69
|
+
|
|
70
|
+
Each pattern has the shape:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
{
|
|
74
|
+
summary: string,
|
|
75
|
+
category: string,
|
|
76
|
+
count: number,
|
|
77
|
+
fingerprint: string,
|
|
78
|
+
providers: Record<string, number>,
|
|
79
|
+
refs: Array<{
|
|
80
|
+
ref: string,
|
|
81
|
+
type: 'pr' | 'discussion',
|
|
82
|
+
excerpt: string,
|
|
83
|
+
provider: string
|
|
84
|
+
}>
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The `type` discriminator on each ref distinguishes whether the feedback originated from a PR review (`source="pr"`) or a plan verdict (`source="plan"`).
|
|
89
|
+
|
|
90
|
+
Cross-provider patterns (multiple providers flagging the same category) lower the effective threshold by 1.
|
|
91
|
+
|
|
92
|
+
### `propose(hub, repo, pattern)`
|
|
93
|
+
|
|
94
|
+
Create a promotion discussion for a detected pattern. The discussion is created in the repo's "Loreli" discussion category with a `loreli:promotion` marker and a structured template.
|
|
95
|
+
|
|
96
|
+
**Parameters:**
|
|
97
|
+
|
|
98
|
+
| Name | Type | Description |
|
|
99
|
+
|------|------|-------------|
|
|
100
|
+
| `hub` | `object` | Hub instance with `category` and `discuss` methods. |
|
|
101
|
+
| `repo` | `string` | `"owner/name"` repository. |
|
|
102
|
+
| `pattern` | `Pattern` | A pattern object from `patterns()`. |
|
|
103
|
+
|
|
104
|
+
**Returns:** `Promise<{ number: number, id: string, url: string }>`
|
|
105
|
+
|
|
106
|
+
The generated discussion body includes:
|
|
107
|
+
|
|
108
|
+
1. **Smart target suggestion** — The most likely promotion target is pre-checked based on feedback source distribution:
|
|
109
|
+
- Discussion-dominated feedback suggests the **Planner prompt** (`.loreli/planner.md`)
|
|
110
|
+
- PR-dominated feedback suggests the **Action prompt** (`.loreli/action.md`)
|
|
111
|
+
- Evenly mixed feedback suggests **AGENTS.md**
|
|
112
|
+
2. **Mixed ref formatting** — Refs are prefixed with `PR #N` or `Discussion #N` based on their type.
|
|
113
|
+
3. **Clear human instructions** — The Decision section tells the human to check one option and close the discussion to trigger the promotion pipeline.
|
|
114
|
+
|
|
115
|
+
Available promotion targets:
|
|
116
|
+
|
|
117
|
+
| Target | File Path | Use Case |
|
|
118
|
+
|--------|-----------|----------|
|
|
119
|
+
| AGENTS.md | `AGENTS.md` | Universal coding convention |
|
|
120
|
+
| Planner prompt | `.loreli/planner.md` | Planning standards |
|
|
121
|
+
| Reviewer prompt | `.loreli/review.md` | Review criteria |
|
|
122
|
+
| Action prompt | `.loreli/action.md` | Implementation guidance |
|
|
123
|
+
| Risk prompt | `.loreli/risk.md` | Risk assessment criteria |
|
|
124
|
+
|
|
125
|
+
### `apply(hub, repo, promotion)`
|
|
126
|
+
|
|
127
|
+
Execute an approved promotion by creating an action issue. Parses the selected target checkbox from the promotion discussion body and includes the concrete file path in the issue.
|
|
128
|
+
|
|
129
|
+
**Parameters:**
|
|
130
|
+
|
|
131
|
+
| Name | Type | Description |
|
|
132
|
+
|------|------|-------------|
|
|
133
|
+
| `hub` | `object` | Hub instance with `open` method. |
|
|
134
|
+
| `repo` | `string` | `"owner/name"` repository. |
|
|
135
|
+
| `promotion.summary` | `string` | Pattern summary for the issue title. |
|
|
136
|
+
| `promotion.discussion` | `number` | Source discussion number. |
|
|
137
|
+
| `promotion.body` | `string` | Full promotion discussion body (used to extract the selected target). |
|
|
138
|
+
|
|
139
|
+
**Returns:** `Promise<{ number: number, url: string }>`
|
|
140
|
+
|
|
141
|
+
The created issue includes a `## Target` section with `**Target file**` pointing to the concrete path (e.g., `.loreli/action.md`), a `## Change` section with the promotion body, and a `## Reference` linking back to the discussion.
|
|
142
|
+
|
|
143
|
+
If no target checkbox is checked, defaults to `AGENTS.md`.
|
|
144
|
+
|
|
145
|
+
### `extractRefs(comments)`
|
|
146
|
+
|
|
147
|
+
Extract unique `#N` references from discussion comment bodies.
|
|
148
|
+
|
|
149
|
+
**Parameters:**
|
|
150
|
+
|
|
151
|
+
| Name | Type | Description |
|
|
152
|
+
|------|------|-------------|
|
|
153
|
+
| `comments` | `Array<{ body: string }>` | Discussion comments. |
|
|
154
|
+
|
|
155
|
+
**Returns:** `number[]` — Unique referenced numbers.
|
|
156
|
+
|
|
157
|
+
### `classifyRefs(comments, refs)`
|
|
158
|
+
|
|
159
|
+
Classify extracted references as blockers or informational using keyword heuristics (e.g., "blocked by #N", "depends on #N").
|
|
160
|
+
|
|
161
|
+
**Parameters:**
|
|
162
|
+
|
|
163
|
+
| Name | Type | Description |
|
|
164
|
+
|------|------|-------------|
|
|
165
|
+
| `comments` | `Array<{ body: string, author?: string }>` | Discussion comments containing the refs. |
|
|
166
|
+
| `refs` | `number[]` | Extracted reference numbers from `extractRefs()`. |
|
|
167
|
+
|
|
168
|
+
**Returns:** `{ blockers: number[], references: number[] }`
|
|
169
|
+
|
|
170
|
+
## Feedback Marker Format
|
|
171
|
+
|
|
172
|
+
The `loreli:feedback` marker is an HTML comment embedded in PR or discussion comments:
|
|
173
|
+
|
|
174
|
+
```html
|
|
175
|
+
<!-- loreli:feedback category="testing" confidence="0.67" provider="openai" source="plan" -->
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
| Attribute | Description |
|
|
179
|
+
|-----------|-------------|
|
|
180
|
+
| `category` | Classification category (naming, architecture, testing, documentation, performance, security). |
|
|
181
|
+
| `confidence` | Classification confidence from `0.00` to `1.00`. |
|
|
182
|
+
| `provider` | LLM provider that generated the feedback (e.g., `openai`, `anthropic`). |
|
|
183
|
+
| `source` | Feedback origin: `pr` (PR review) or `plan` (plan verdict). |
|
|
184
|
+
|
|
185
|
+
## Human Approval Workflow
|
|
186
|
+
|
|
187
|
+
1. **Detection** — The `knowledge` reactor in `start.js` calls `patterns()` each tick. When a category crosses the threshold, `propose()` creates a promotion discussion.
|
|
188
|
+
2. **Review** — Repo maintainers receive a GitHub notification. The discussion shows the recurring pattern with PR/discussion references, cross-provider consensus, and a suggested promotion target.
|
|
189
|
+
3. **Decision** — The human checks one of: `[x] Approve` or `[x] Reject`, then **closes the discussion**.
|
|
190
|
+
4. **Execution** — The `promotion-apply` reactor detects the closed, approved discussion and calls `apply()`, which creates an action issue targeting the selected file. An action agent picks up the issue and implements the change.
|
|
191
|
+
|
|
192
|
+
## Configuration
|
|
193
|
+
|
|
194
|
+
```yaml
|
|
195
|
+
# loreli.yml
|
|
196
|
+
feedback:
|
|
197
|
+
enabled: true
|
|
198
|
+
threshold: 5
|
|
199
|
+
categories:
|
|
200
|
+
- naming
|
|
201
|
+
- architecture
|
|
202
|
+
- testing
|
|
203
|
+
- documentation
|
|
204
|
+
- performance
|
|
205
|
+
- security
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### How each setting affects the system
|
|
209
|
+
|
|
210
|
+
**`enabled`** — Master switch. When `false`, no `loreli:feedback` markers are posted during PR reviews (`forward()` in `loreli/review` checks this before calling `classify()`). Plan verdict classification in `plan/verdict` does not check this flag because the MCP tool context lacks config access. Pattern detection and promotion reactors also check this flag before running.
|
|
211
|
+
|
|
212
|
+
**`threshold`** — The minimum number of `loreli:feedback` markers in a single category before `patterns()` reports it as a recurring pattern. A pattern with 4 occurrences in a system configured with `threshold: 5` is invisible to the promotion pipeline. Cross-provider consensus (multiple LLM providers flagging the same category) lowers the effective threshold by 1, so a cross-provider pattern needs only 4 occurrences at the default threshold.
|
|
213
|
+
|
|
214
|
+
**`categories`** — The list of categories that `classify()` is allowed to score against during PR review feedback capture. This acts as a **gate at the capture stage**: removing a category from this list means PR review feedback matching that category will never produce a `loreli:feedback` marker, which transitively means `patterns()` will never detect patterns in that category from PRs, and `propose()` will never create a promotion discussion for it.
|
|
215
|
+
|
|
216
|
+
The six built-in categories and what they detect:
|
|
217
|
+
|
|
218
|
+
| Category | Keywords | What it captures |
|
|
219
|
+
|----------|----------|------------------|
|
|
220
|
+
| `naming` | name, rename, prefix, convention, camelCase | Naming convention violations and style inconsistencies |
|
|
221
|
+
| `architecture` | architect, structure, module, package, refactor, decouple | Structural concerns, module boundaries, coupling issues |
|
|
222
|
+
| `testing` | test, coverage, assert, fixture, TDD, mock | Missing tests, inadequate coverage, testing methodology |
|
|
223
|
+
| `documentation` | docs, document, README, JSDoc, comment, explain | Missing or incomplete documentation |
|
|
224
|
+
| `performance` | perform, optimize, slow, memory, N+1, cache | Performance issues, optimization opportunities |
|
|
225
|
+
| `security` | secure, secret, token, auth, vulnerable, inject | Security vulnerabilities, credential exposure, auth issues |
|
|
226
|
+
|
|
227
|
+
Categories cannot be extended via config — the keyword patterns are hardcoded in `KEYWORDS`. To add a new category, add an entry to `KEYWORDS` in `packages/knowledge/src/index.js` and include it in the `feedback.categories` default in `packages/config/src/defaults.js`.
|
|
228
|
+
|
|
229
|
+
Plan verdict classification (`plan/verdict` in `packages/mcp/src/tools/github.js`) uses all hardcoded categories regardless of the config list, because the MCP tool context does not have access to the orchestrator config. This means plan feedback is always classified against all six categories even if the config restricts PR feedback to a subset.
|
|
230
|
+
|
|
231
|
+
## Errors
|
|
232
|
+
|
|
233
|
+
| Error | Cause | Resolution |
|
|
234
|
+
|-------|-------|------------|
|
|
235
|
+
| `category: not implemented` | Hub instance lacks the `category()` method. | Ensure the hub is a `GitHubHub` instance, not the base stub. |
|
|
236
|
+
| `discussions: not implemented` | Hub instance lacks the `discussions()` method. | Same as above. |
|
|
237
|
+
| Non-fatal catch blocks | Individual PR/discussion comment fetches may fail due to permissions or rate limits. | Patterns are still reported from successful fetches. Check GitHub token scopes if patterns seem incomplete. |
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowledge capture engine — the write path.
|
|
3
|
+
*
|
|
4
|
+
* Classifies review feedback, detects recurring patterns across PRs,
|
|
5
|
+
* proposes promotions via GitHub Discussions, and applies approved
|
|
6
|
+
* promotions by creating issues.
|
|
7
|
+
*
|
|
8
|
+
* @module loreli/knowledge
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { mark, parse } from 'loreli/marker';
|
|
12
|
+
import { logger } from 'loreli/log';
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
|
|
15
|
+
const log = logger('knowledge');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Keyword patterns for heuristic feedback classification.
|
|
19
|
+
* Each entry maps a category to an array of regex patterns.
|
|
20
|
+
*
|
|
21
|
+
* @type {Record<string, RegExp[]>}
|
|
22
|
+
*/
|
|
23
|
+
const KEYWORDS = {
|
|
24
|
+
naming: [/\bnam(e|ing)\b/i, /\brename\b/i, /\bprefix\b/i, /\bconvention\b/i, /\bcamelCase\b/i],
|
|
25
|
+
architecture: [/\barchitect/i, /\bstructur/i, /\bmodule\b/i, /\bpackage\b/i, /\brefactor/i, /\bdecouple/i],
|
|
26
|
+
testing: [/\btest/i, /\bcoverage\b/i, /\bassert/i, /\bfixture/i, /\bTDD\b/i, /\bmock\b/i],
|
|
27
|
+
documentation: [/\bdoc(s|ument)/i, /\bREADME\b/i, /\bJSDoc\b/i, /\bcomment/i, /\bexplain/i],
|
|
28
|
+
performance: [/\bperform/i, /\boptimiz/i, /\bslow\b/i, /\bmemory\b/i, /\bN\+1\b/i, /\bcache\b/i],
|
|
29
|
+
security: [/\bsecur/i, /\bsecret/i, /\btoken\b/i, /\bauth/i, /\bvulnerab/i, /\binject/i]
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Classify review feedback text into a category using keyword heuristics.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} feedback - Review comment text.
|
|
36
|
+
* @param {object} [opts] - Options.
|
|
37
|
+
* @param {string[]} [opts.categories] - Allowed categories (defaults to all).
|
|
38
|
+
* @returns {{category: string, confidence: number}}
|
|
39
|
+
*/
|
|
40
|
+
export function classify(feedback, opts = {}) {
|
|
41
|
+
const allowed = opts.categories ?? Object.keys(KEYWORDS);
|
|
42
|
+
const scores = {};
|
|
43
|
+
|
|
44
|
+
for (const cat of allowed) {
|
|
45
|
+
const patterns = KEYWORDS[cat];
|
|
46
|
+
if (!patterns) continue;
|
|
47
|
+
scores[cat] = 0;
|
|
48
|
+
for (const re of patterns) {
|
|
49
|
+
if (re.test(feedback)) scores[cat]++;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let best = 'documentation';
|
|
54
|
+
let max = 0;
|
|
55
|
+
for (const [cat, score] of Object.entries(scores)) {
|
|
56
|
+
if (score > max) {
|
|
57
|
+
max = score;
|
|
58
|
+
best = cat;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const confidence = max > 0 ? Math.min(max / 3, 1) : 0;
|
|
63
|
+
return { category: best, confidence };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Compute a stable fingerprint for a pattern summary.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} summary - Pattern summary text.
|
|
70
|
+
* @returns {string} Hex digest (first 12 chars).
|
|
71
|
+
*/
|
|
72
|
+
function fingerprint(summary) {
|
|
73
|
+
return createHash('sha256').update(summary).digest('hex').slice(0, 12);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Scan review comments for loreli:feedback markers and detect
|
|
78
|
+
* recurring patterns above the configured threshold.
|
|
79
|
+
*
|
|
80
|
+
* @param {object} hub - Hub instance.
|
|
81
|
+
* @param {string} repo - "owner/name" repository.
|
|
82
|
+
* @param {object} [opts] - Options.
|
|
83
|
+
* @param {number} [opts.threshold=5] - Minimum occurrences.
|
|
84
|
+
* @param {string} [opts.category] - Filter to a specific category.
|
|
85
|
+
* @returns {Promise<Array<{summary: string, category: string, count: number, fingerprint: string, providers: Record<string, number>, refs: Array<{ref: string, type: 'pr'|'discussion', excerpt: string, provider: string}>}>>}
|
|
86
|
+
*/
|
|
87
|
+
export async function patterns(hub, repo, opts = {}) {
|
|
88
|
+
const threshold = opts.threshold ?? 5;
|
|
89
|
+
const buckets = {};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {string} key - Category key.
|
|
93
|
+
* @param {{ref: string, type: string, excerpt: string, provider: string}} item
|
|
94
|
+
*/
|
|
95
|
+
function push(key, item) {
|
|
96
|
+
if (!buckets[key]) buckets[key] = { items: [], providers: {} };
|
|
97
|
+
buckets[key].items.push(item);
|
|
98
|
+
buckets[key].providers[item.provider] = (buckets[key].providers[item.provider] ?? 0) + 1;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const results = await hub.searchIssues(repo, 'loreli:feedback');
|
|
102
|
+
for (const item of results) {
|
|
103
|
+
if (item.type !== 'pr') continue;
|
|
104
|
+
try {
|
|
105
|
+
const comments = await hub.comments(repo, item.number);
|
|
106
|
+
for (const c of comments) {
|
|
107
|
+
const data = parse(c.body, 'feedback');
|
|
108
|
+
if (!data?.category) continue;
|
|
109
|
+
if (opts.category && data.category !== opts.category) continue;
|
|
110
|
+
|
|
111
|
+
push(data.category, {
|
|
112
|
+
ref: String(item.number),
|
|
113
|
+
type: 'pr',
|
|
114
|
+
excerpt: c.body.slice(0, 120).replace(/<!--[^>]*-->/g, '').trim(),
|
|
115
|
+
provider: data.provider ?? 'unknown'
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
} catch { /* non-fatal */ }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const cat = await hub.category(repo, 'Loreli');
|
|
123
|
+
const discussions = await hub.discussions(repo, cat.id);
|
|
124
|
+
for (const disc of discussions) {
|
|
125
|
+
try {
|
|
126
|
+
const comments = await hub.discussionComments(disc.id);
|
|
127
|
+
for (const c of comments) {
|
|
128
|
+
const data = parse(c.body, 'feedback');
|
|
129
|
+
if (!data?.category) continue;
|
|
130
|
+
if (opts.category && data.category !== opts.category) continue;
|
|
131
|
+
|
|
132
|
+
push(data.category, {
|
|
133
|
+
ref: String(disc.number),
|
|
134
|
+
type: 'discussion',
|
|
135
|
+
excerpt: c.body.slice(0, 120).replace(/<!--[^>]*-->/g, '').trim(),
|
|
136
|
+
provider: data.provider ?? 'unknown'
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
} catch { /* non-fatal */ }
|
|
140
|
+
}
|
|
141
|
+
} catch { /* non-fatal — discussions may not be available */ }
|
|
142
|
+
|
|
143
|
+
const found = [];
|
|
144
|
+
for (const [category, bucket] of Object.entries(buckets)) {
|
|
145
|
+
if (bucket.items.length < threshold) continue;
|
|
146
|
+
|
|
147
|
+
const crossProvider = Object.keys(bucket.providers).length > 1;
|
|
148
|
+
const effective = crossProvider ? threshold - 1 : threshold;
|
|
149
|
+
if (bucket.items.length < effective) continue;
|
|
150
|
+
|
|
151
|
+
found.push({
|
|
152
|
+
summary: `${category} feedback pattern (${bucket.items.length} occurrences)`,
|
|
153
|
+
category,
|
|
154
|
+
count: bucket.items.length,
|
|
155
|
+
fingerprint: fingerprint(`${category}:${bucket.items.length}`),
|
|
156
|
+
providers: bucket.providers,
|
|
157
|
+
refs: bucket.items
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return found;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Determine the suggested promotion target based on feedback source distribution.
|
|
166
|
+
*
|
|
167
|
+
* @param {Array<{type: string}>} refs - Pattern refs with type discriminator.
|
|
168
|
+
* @returns {'planner'|'action'|'agents'}
|
|
169
|
+
*/
|
|
170
|
+
function target(refs) {
|
|
171
|
+
const planCount = refs.filter(function isDisc(r) { return r.type === 'discussion'; }).length;
|
|
172
|
+
const prCount = refs.filter(function isPr(r) { return r.type === 'pr'; }).length;
|
|
173
|
+
if (planCount > prCount) return 'planner';
|
|
174
|
+
if (prCount > planCount) return 'action';
|
|
175
|
+
return 'agents';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Map of target keys to their display labels.
|
|
180
|
+
*
|
|
181
|
+
* @type {Record<string, string>}
|
|
182
|
+
*/
|
|
183
|
+
const TARGETS = {
|
|
184
|
+
agents: 'AGENTS.md -- universal coding convention',
|
|
185
|
+
planner: 'Planner prompt -- planning standards (.loreli/planner.md)',
|
|
186
|
+
reviewer: 'Reviewer prompt -- review criteria (.loreli/review.md)',
|
|
187
|
+
action: 'Action prompt -- implementation guidance (.loreli/action.md)',
|
|
188
|
+
risk: 'Risk prompt -- risk assessment criteria (.loreli/risk.md)'
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Create a promotion discussion for a detected pattern.
|
|
193
|
+
*
|
|
194
|
+
* The template includes a smart target suggestion pre-checked based on
|
|
195
|
+
* feedback source distribution, mixed PR/discussion ref formatting, and
|
|
196
|
+
* clear human instructions for the decision workflow.
|
|
197
|
+
*
|
|
198
|
+
* @param {object} hub - Hub instance.
|
|
199
|
+
* @param {string} repo - "owner/name" repository.
|
|
200
|
+
* @param {object} pattern - Pattern from patterns().
|
|
201
|
+
* @returns {Promise<{number: number, id: string, url: string}>}
|
|
202
|
+
*/
|
|
203
|
+
export async function propose(hub, repo, pattern) {
|
|
204
|
+
const fp = pattern.fingerprint;
|
|
205
|
+
const providerLine = Object.entries(pattern.providers)
|
|
206
|
+
.map(function fmt([p, n]) { return `${p} (${n})`; })
|
|
207
|
+
.join(', ');
|
|
208
|
+
|
|
209
|
+
const refs = pattern.refs
|
|
210
|
+
.map(function fmt(r) {
|
|
211
|
+
const prefix = r.type === 'discussion' ? 'Discussion' : 'PR';
|
|
212
|
+
return `- ${prefix} #${r.ref}: "${r.excerpt}"`;
|
|
213
|
+
})
|
|
214
|
+
.join('\n');
|
|
215
|
+
|
|
216
|
+
const suggested = target(pattern.refs);
|
|
217
|
+
const targetCheckboxes = Object.entries(TARGETS)
|
|
218
|
+
.map(function fmt([key, label]) {
|
|
219
|
+
const checked = key === suggested ? 'x' : ' ';
|
|
220
|
+
return `- [${checked}] ${label}`;
|
|
221
|
+
})
|
|
222
|
+
.join('\n');
|
|
223
|
+
|
|
224
|
+
const body = [
|
|
225
|
+
mark('promotion', { fingerprint: fp, category: pattern.category, status: 'pending' }),
|
|
226
|
+
'',
|
|
227
|
+
'## Pattern',
|
|
228
|
+
'',
|
|
229
|
+
`This review feedback appeared in ${pattern.count} reviews:`,
|
|
230
|
+
refs,
|
|
231
|
+
'',
|
|
232
|
+
`**Category**: ${pattern.category}`,
|
|
233
|
+
`**Providers**: ${providerLine}`,
|
|
234
|
+
'',
|
|
235
|
+
'## Proposed Promotion',
|
|
236
|
+
'',
|
|
237
|
+
'Select the promotion target (suggested target is pre-selected based on feedback source):',
|
|
238
|
+
'',
|
|
239
|
+
targetCheckboxes,
|
|
240
|
+
'',
|
|
241
|
+
'> _Describe the rule or convention to promote here._',
|
|
242
|
+
'',
|
|
243
|
+
'## Decision',
|
|
244
|
+
'',
|
|
245
|
+
'Check **one** option below, then **close this discussion** to trigger the promotion pipeline.',
|
|
246
|
+
'',
|
|
247
|
+
'- [ ] Approve -- apply this as a standard',
|
|
248
|
+
'- [ ] Reject -- this is a preference, not a standard',
|
|
249
|
+
'',
|
|
250
|
+
'> Closing without checking a box takes no action. The discussion can be reopened to reconsider.'
|
|
251
|
+
].join('\n');
|
|
252
|
+
|
|
253
|
+
const cat = await hub.category(repo, 'Loreli');
|
|
254
|
+
const disc = await hub.discuss(repo, {
|
|
255
|
+
title: `Promotion: ${pattern.summary}`,
|
|
256
|
+
body,
|
|
257
|
+
categoryId: cat.id,
|
|
258
|
+
repositoryId: cat.repositoryId
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
log.info(`propose: created promotion discussion #${disc.number} for ${pattern.category}`);
|
|
262
|
+
return disc;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Map of target checkbox labels to concrete file paths.
|
|
267
|
+
*
|
|
268
|
+
* @type {Record<string, string>}
|
|
269
|
+
*/
|
|
270
|
+
const TARGET_FILES = {
|
|
271
|
+
'AGENTS.md': 'AGENTS.md',
|
|
272
|
+
'Planner prompt': '.loreli/planner.md',
|
|
273
|
+
'Reviewer prompt': '.loreli/review.md',
|
|
274
|
+
'Action prompt': '.loreli/action.md',
|
|
275
|
+
'Risk prompt': '.loreli/risk.md'
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Parse the selected target from a promotion discussion body by finding
|
|
280
|
+
* the first checked checkbox (`- [x]`) that matches a known target label.
|
|
281
|
+
*
|
|
282
|
+
* @param {string} body - Promotion discussion body.
|
|
283
|
+
* @returns {{label: string, file: string}}
|
|
284
|
+
*/
|
|
285
|
+
function extractTarget(body) {
|
|
286
|
+
const checked = /- \[x\] (\w[\w\s]*?)(?:\s+--|\s*$)/gm;
|
|
287
|
+
let m;
|
|
288
|
+
while ((m = checked.exec(body)) !== null) {
|
|
289
|
+
const label = m[1].trim();
|
|
290
|
+
if (TARGET_FILES[label]) return { label, file: TARGET_FILES[label] };
|
|
291
|
+
}
|
|
292
|
+
return { label: 'AGENTS.md', file: 'AGENTS.md' };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Execute an approved promotion by creating an action issue.
|
|
297
|
+
*
|
|
298
|
+
* Parses the selected promotion target from the discussion body and
|
|
299
|
+
* includes the concrete file path in the action issue so the
|
|
300
|
+
* implementing agent knows exactly which file to update.
|
|
301
|
+
*
|
|
302
|
+
* @param {object} hub - Hub instance.
|
|
303
|
+
* @param {string} repo - "owner/name" repository.
|
|
304
|
+
* @param {object} promotion - Promotion data.
|
|
305
|
+
* @param {string} promotion.summary - Pattern summary.
|
|
306
|
+
* @param {number} promotion.discussion - Discussion number.
|
|
307
|
+
* @param {string} promotion.body - Full promotion discussion body.
|
|
308
|
+
* @returns {Promise<{number: number, url: string}>}
|
|
309
|
+
*/
|
|
310
|
+
export async function apply(hub, repo, promotion) {
|
|
311
|
+
const { label, file } = extractTarget(promotion.body);
|
|
312
|
+
|
|
313
|
+
const body = [
|
|
314
|
+
`Apply the approved promotion from discussion #${promotion.discussion}.`,
|
|
315
|
+
'',
|
|
316
|
+
'## Target',
|
|
317
|
+
'',
|
|
318
|
+
`**Target file**: \`${file}\``,
|
|
319
|
+
`**Selected by**: ${label}`,
|
|
320
|
+
'',
|
|
321
|
+
'## Change',
|
|
322
|
+
'',
|
|
323
|
+
promotion.body,
|
|
324
|
+
'',
|
|
325
|
+
'## Reference',
|
|
326
|
+
'',
|
|
327
|
+
`Promotion discussion: #${promotion.discussion}`
|
|
328
|
+
].join('\n');
|
|
329
|
+
|
|
330
|
+
const issue = await hub.open(repo, {
|
|
331
|
+
title: `Apply promotion: ${promotion.summary}`,
|
|
332
|
+
body,
|
|
333
|
+
labels: ['loreli', 'loreli:action']
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
log.info(`apply: created action issue #${issue.number} for promotion from #${promotion.discussion}`);
|
|
337
|
+
return issue;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Blocker signal patterns (case-insensitive). #REF is replaced with the
|
|
342
|
+
* actual #N reference when testing. A ref matches as a blocker if any
|
|
343
|
+
* pattern matches the joined comment text.
|
|
344
|
+
*
|
|
345
|
+
* @type {RegExp[]}
|
|
346
|
+
*/
|
|
347
|
+
const BLOCKER_SIGNALS = [
|
|
348
|
+
/\bneed(?:s|ed)?\b.*#REF\b/i,
|
|
349
|
+
/\bblock(?:s|ed|ing)?\s+(?:by\s+)?#REF\b/i,
|
|
350
|
+
/\bdepend(?:s|ing)?\s+on\s+#REF\b/i,
|
|
351
|
+
/\bwait(?:s|ing)?\s+(?:for|on)\s+#REF\b/i,
|
|
352
|
+
/\brequir(?:es?|ing)\b.*#REF\b/i,
|
|
353
|
+
/\bprerequisite\b.*#REF\b/i,
|
|
354
|
+
/\bafter\s+#REF\s+(?:is\s+)?(?:resolved|merged|closed)/i,
|
|
355
|
+
/\bbefore\s+(?:we|this)\s+can\b.*#REF\b/i,
|
|
356
|
+
/\bcan(?:no|')?t\s+(?:proceed|start|continue)\b.*#REF\b/i
|
|
357
|
+
];
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Extract unique issue/PR number references from discussion comments.
|
|
361
|
+
* Matches `#N` patterns in comment bodies.
|
|
362
|
+
*
|
|
363
|
+
* @param {Array<{body: string, author?: string}>} comments - Discussion comments.
|
|
364
|
+
* @returns {number[]} Unique referenced numbers.
|
|
365
|
+
*/
|
|
366
|
+
export function extractRefs(comments) {
|
|
367
|
+
const refs = new Set();
|
|
368
|
+
for (const c of comments) {
|
|
369
|
+
for (const m of c.body.matchAll(/#(\d+)/g)) refs.add(Number(m[1]));
|
|
370
|
+
}
|
|
371
|
+
return [...refs];
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Classify issue/PR references as blockers or informational using keyword
|
|
376
|
+
* heuristics. For each ref, scans the comment text around that ref for
|
|
377
|
+
* blocker signal patterns. Returns structured classification so the caller
|
|
378
|
+
* can verify blockers against the hub before parking a discussion.
|
|
379
|
+
*
|
|
380
|
+
* @param {Array<{body: string, author?: string}>} comments - Discussion comments containing the refs.
|
|
381
|
+
* @param {number[]} refs - Extracted reference numbers.
|
|
382
|
+
* @returns {{blockers: number[], references: number[]}}
|
|
383
|
+
*/
|
|
384
|
+
export function classifyRefs(comments, refs) {
|
|
385
|
+
if (!refs.length) return { blockers: [], references: [] };
|
|
386
|
+
|
|
387
|
+
const text = comments.map(function format(c) {
|
|
388
|
+
return c.author ? `${c.author}: ${c.body}` : c.body;
|
|
389
|
+
}).join('\n');
|
|
390
|
+
|
|
391
|
+
const blockers = [];
|
|
392
|
+
const references = [];
|
|
393
|
+
|
|
394
|
+
for (const ref of refs) {
|
|
395
|
+
const refStr = '#' + ref;
|
|
396
|
+
let isBlocker = false;
|
|
397
|
+
|
|
398
|
+
for (const pattern of BLOCKER_SIGNALS) {
|
|
399
|
+
const source = pattern.source.replace(/#REF\b/g, refStr);
|
|
400
|
+
const re = new RegExp(source, pattern.flags);
|
|
401
|
+
if (re.test(text)) {
|
|
402
|
+
isBlocker = true;
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (isBlocker) blockers.push(ref);
|
|
408
|
+
else references.push(ref);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return { blockers, references };
|
|
412
|
+
}
|