@sushanglewis/lincoln 1.6.2 → 1.7.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/framework/.claude/agents/pm.md +2 -2
- package/framework/.claude/agents/security-reviewer.md +57 -0
- package/framework/.claude/harnesses/codex.yaml +31 -2
- package/framework/.claude/harnesses/command-map.yaml +4 -0
- package/framework/.claude/harnesses/opencode.yaml +33 -3
- package/framework/.claude/hooks/hooks.yaml +37 -0
- package/framework/.claude/hooks/on-session-start.sh +20 -5
- package/framework/.claude/hooks/on-stop.sh +22 -0
- package/framework/.claude/hooks/pre-tool-use.sh +22 -0
- package/framework/.claude/mcp/mcp.yaml +34 -0
- package/framework/.claude/policies/friction.yaml +18 -0
- package/framework/.claude/policies/security.yaml +46 -0
- package/framework/.claude/schemas/design-review-feedback.json +32 -0
- package/framework/.claude/schemas/stage-definition.schema.json +22 -0
- package/framework/.claude/skills/clarify-requirements/SKILL.md +1 -0
- package/framework/.claude/skills/clarify-requirements/prompts/main.md +83 -80
- package/framework/.claude/skills/draft-product-design/prompts/main.md +27 -14
- package/framework/.claude/stages/clarify.yaml +7 -5
- package/framework/.claude/stages/phase-1-p0-design.yaml +44 -0
- package/framework/.claude/stages/phase-1-p0-implement.yaml +47 -0
- package/framework/.claude/stages/phase-2-p1-design.yaml +53 -0
- package/framework/.claude/stages/phase-2-p1-implement.yaml +45 -0
- package/framework/.claude/stages/product-design-docs.yaml +6 -6
- package/framework/.claude/workflows/framework-self-improvement.yaml +100 -0
- package/framework/.claude/workflows/interview-to-knowledge.yaml +1 -0
- package/framework/.claude-plugin/marketplace.json +2 -2
- package/framework/.claude-plugin/plugin.json +1 -1
- package/framework/.version-bump.json +1 -1
- package/framework/README.en.md +24 -5
- package/framework/README.md +24 -5
- package/framework/RELEASE.md +41 -0
- package/framework/SKILL.md +15 -6
- package/framework/manifest.json +96 -56
- package/framework/scripts/check-harness-drift.sh +11 -1
- package/framework/scripts/lincoln-setup.py +17 -0
- package/framework/scripts/lincoln_condenser.py +110 -0
- package/framework/scripts/lincoln_friction.py +266 -0
- package/framework/scripts/lincoln_harness_adapter.py +185 -13
- package/framework/scripts/lincoln_mr_mine.py +146 -0
- package/framework/scripts/lincoln_recall.py +142 -0
- package/framework/scripts/lincoln_render.py +209 -6
- package/framework/scripts/lincoln_trace.py +119 -3
- package/framework/scripts/security_analyzer.py +234 -0
- package/framework/scripts/stage_loader.py +124 -1
- package/framework/tools/lincoln/package-lock.json +2 -2
- package/framework/tools/lincoln/package.json +1 -1
- package/framework/tools/lincoln-installer/package.json +6 -1
- package/package.json +6 -1
- package/framework/.claude/agents/external/everything-claude-code/agents/ecc-tdd-guide.md +0 -108
- package/framework/.claude/agents/external/oh-my-claudecode/agents/omc-architect.md +0 -134
- package/framework/.claude/agents/external/oh-my-claudecode/agents/omc-code-reviewer.md +0 -248
- package/framework/.claude/agents/external/oh-my-claudecode/agents/omc-designer.md +0 -122
- package/framework/.claude/agents/external/oh-my-claudecode/agents/omc-qa-tester.md +0 -106
- package/framework/.claude/agents/external/wshobson-agents/agents/wshobson-frontend-developer.md +0 -173
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Harness adapter generator for Lincoln.
|
|
3
3
|
|
|
4
|
-
Derives codex/opencode artifacts (AGENTS.md, prompts, agents, commands
|
|
5
|
-
the single source of truth in `.claude/`, driven by
|
|
6
|
-
manifests. Idempotent; `--check` regenerates
|
|
4
|
+
Derives codex/opencode artifacts (AGENTS.md, prompts, agents, commands, hooks,
|
|
5
|
+
MCP config) from the single source of truth in `.claude/`, driven by
|
|
6
|
+
`.claude/harnesses/<name>.yaml` manifests. Idempotent; `--check` regenerates
|
|
7
|
+
into a temp dir and reports drift.
|
|
7
8
|
|
|
8
9
|
Usage:
|
|
9
10
|
python3 scripts/lincoln_harness_adapter.py --harness opencode
|
|
10
11
|
python3 scripts/lincoln_harness_adapter.py --harness codex --check
|
|
12
|
+
python3 scripts/lincoln_harness_adapter.py --harness claude-code --check
|
|
11
13
|
"""
|
|
12
14
|
|
|
13
15
|
from __future__ import annotations
|
|
@@ -37,6 +39,9 @@ human_gate 阶段必须获得人类 PM 显式确认后才可推进,不得跳过
|
|
|
37
39
|
|
|
38
40
|
FRONTMATTER_WHITELIST = ("description", "mode", "model", "permission", "tools")
|
|
39
41
|
|
|
42
|
+
MCP_SOURCE_PATH = Path(".claude") / "mcp" / "mcp.yaml"
|
|
43
|
+
HOOKS_SOURCE_PATH = Path(".claude") / "hooks" / "hooks.yaml"
|
|
44
|
+
|
|
40
45
|
|
|
41
46
|
class HarnessAdapterError(Exception):
|
|
42
47
|
"""Raised when a harness manifest is missing or invalid."""
|
|
@@ -91,9 +96,50 @@ def _header(harness: str, source: str) -> str:
|
|
|
91
96
|
return f"<!-- {GENERATED_NOTE} (generated by {GENERATOR}; harness: {harness}; source: {source}) -->"
|
|
92
97
|
|
|
93
98
|
|
|
94
|
-
def
|
|
99
|
+
def _glob_base(root: Path, pattern: str) -> Path:
|
|
100
|
+
"""Return the concrete base directory of a glob pattern relative to root.
|
|
101
|
+
|
|
102
|
+
Examples:
|
|
103
|
+
.claude/skills/*/SKILL.md -> root/.claude/skills
|
|
104
|
+
.claude/rules/**/*.md -> root/.claude/rules
|
|
105
|
+
.claude/hooks/*.sh -> root/.claude/hooks
|
|
106
|
+
"""
|
|
107
|
+
parts = Path(pattern).parts
|
|
108
|
+
concrete: list[str] = []
|
|
109
|
+
for part in parts:
|
|
110
|
+
if any(c in part for c in "*?["):
|
|
111
|
+
break
|
|
112
|
+
concrete.append(part)
|
|
113
|
+
return root / Path(*concrete)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _resolve_output(
|
|
117
|
+
template: str,
|
|
118
|
+
source: Path,
|
|
119
|
+
root: Path,
|
|
120
|
+
pattern: str,
|
|
121
|
+
project_dir: Path,
|
|
122
|
+
home_dir: Path,
|
|
123
|
+
) -> Path:
|
|
124
|
+
"""Render an output path template using source path metadata."""
|
|
125
|
+
name = source.stem
|
|
126
|
+
parent = source.parent.name
|
|
127
|
+
base = _glob_base(root, pattern)
|
|
128
|
+
try:
|
|
129
|
+
rel = source.relative_to(base)
|
|
130
|
+
relative_path = str(rel.with_suffix(""))
|
|
131
|
+
relative_full = str(rel)
|
|
132
|
+
except ValueError:
|
|
133
|
+
relative_path = name
|
|
134
|
+
relative_full = source.name
|
|
135
|
+
|
|
95
136
|
rendered = template.format(
|
|
96
|
-
project=str(project_dir),
|
|
137
|
+
project=str(project_dir),
|
|
138
|
+
home=str(home_dir),
|
|
139
|
+
name=name,
|
|
140
|
+
parent=parent,
|
|
141
|
+
relative_path=relative_path,
|
|
142
|
+
relative_full=relative_full,
|
|
97
143
|
)
|
|
98
144
|
return Path(rendered)
|
|
99
145
|
|
|
@@ -162,6 +208,46 @@ def _transform_plugin_json(text: str, harness: str, source: str) -> str:
|
|
|
162
208
|
return json.dumps(manifest, indent=2, ensure_ascii=False) + "\n"
|
|
163
209
|
|
|
164
210
|
|
|
211
|
+
def _transform_mcp_config(text: str, harness: str, source: str) -> str:
|
|
212
|
+
"""Convert Lincoln's declarative mcp.yaml to a harness-native MCP config.
|
|
213
|
+
|
|
214
|
+
The output is a JSON object keyed by `mcpServers`. Harness overrides declared
|
|
215
|
+
under each server's `harness_overrides` are applied for the target harness.
|
|
216
|
+
Environment variable placeholders (`${VAR}`) are preserved; the runtime
|
|
217
|
+
environment or `.env` file is responsible for resolving them.
|
|
218
|
+
"""
|
|
219
|
+
try:
|
|
220
|
+
data = yaml.safe_load(text)
|
|
221
|
+
except yaml.YAMLError as exc:
|
|
222
|
+
raise HarnessAdapterError(f"Invalid MCP config YAML ({source}): {exc}") from exc
|
|
223
|
+
if not isinstance(data, dict):
|
|
224
|
+
raise HarnessAdapterError(f"MCP config must be a mapping: {source}")
|
|
225
|
+
|
|
226
|
+
servers = data.get("servers", {})
|
|
227
|
+
if not isinstance(servers, dict):
|
|
228
|
+
raise HarnessAdapterError(f"MCP config 'servers' must be a mapping: {source}")
|
|
229
|
+
|
|
230
|
+
rendered: dict[str, Any] = {}
|
|
231
|
+
for server_name, server in servers.items():
|
|
232
|
+
if not isinstance(server, dict):
|
|
233
|
+
continue
|
|
234
|
+
overrides = server.get("harness_overrides", {}).get(harness, {})
|
|
235
|
+
enabled = server.get("enabled", True)
|
|
236
|
+
if isinstance(overrides.get("enabled"), bool):
|
|
237
|
+
enabled = overrides["enabled"]
|
|
238
|
+
if not enabled:
|
|
239
|
+
continue
|
|
240
|
+
|
|
241
|
+
entry: dict[str, Any] = {}
|
|
242
|
+
for key in ("command", "args", "env", "type", "url", "headers", "description"):
|
|
243
|
+
if key in server:
|
|
244
|
+
entry[key] = server[key]
|
|
245
|
+
entry.update({k: v for k, v in overrides.items() if k != "enabled"})
|
|
246
|
+
rendered[server_name] = entry
|
|
247
|
+
|
|
248
|
+
return json.dumps({"mcpServers": rendered}, indent=2, ensure_ascii=False) + "\n"
|
|
249
|
+
|
|
250
|
+
|
|
165
251
|
def _transform_concat(
|
|
166
252
|
texts: list[tuple[str, str]], harness: str, sources: str, manifest: dict[str, Any]
|
|
167
253
|
) -> str:
|
|
@@ -195,32 +281,49 @@ def _transform_command_template(cmd_name: str, cmd: dict[str, Any], harness: str
|
|
|
195
281
|
|
|
196
282
|
|
|
197
283
|
def _expand_target(
|
|
198
|
-
root: Path,
|
|
199
|
-
|
|
200
|
-
|
|
284
|
+
root: Path,
|
|
285
|
+
target: dict[str, Any],
|
|
286
|
+
manifest: dict[str, Any],
|
|
287
|
+
project_dir: Path,
|
|
288
|
+
home_dir: Path,
|
|
289
|
+
) -> list[tuple[Path, str, str]]:
|
|
290
|
+
"""Expand a target into (output_path, content, source_label) tuples."""
|
|
201
291
|
kind = target.get("kind")
|
|
202
292
|
transform = target.get("transform", "copy")
|
|
203
293
|
source = target.get("source", "")
|
|
294
|
+
output_template = target.get("output", "")
|
|
204
295
|
harness = manifest["name"]
|
|
205
296
|
|
|
206
297
|
if source == "command_map" or kind == "command":
|
|
207
298
|
items = []
|
|
208
299
|
for cmd_name, cmd in manifest.get("command_map", {}).items():
|
|
209
300
|
content = _transform_command_template(cmd_name, cmd, harness)
|
|
210
|
-
|
|
301
|
+
out_path = _resolve_output(
|
|
302
|
+
output_template,
|
|
303
|
+
Path(f"{cmd_name}.md"),
|
|
304
|
+
root,
|
|
305
|
+
source,
|
|
306
|
+
project_dir=project_dir,
|
|
307
|
+
home_dir=home_dir,
|
|
308
|
+
)
|
|
309
|
+
items.append((out_path, content, "command_map"))
|
|
211
310
|
return items
|
|
212
311
|
|
|
213
312
|
sources = sorted(root.glob(source))
|
|
214
313
|
if not sources:
|
|
314
|
+
if target.get("optional"):
|
|
315
|
+
return []
|
|
215
316
|
raise HarnessAdapterError(f"Target source matched nothing: {source}")
|
|
216
317
|
|
|
217
318
|
if transform == "concat":
|
|
218
319
|
texts = [(p.stem, p.read_text(encoding="utf-8")) for p in sources]
|
|
219
|
-
|
|
320
|
+
out_path = _resolve_output(output_template, sources[0], root, source, project_dir, home_dir)
|
|
321
|
+
return [(out_path, _transform_concat(texts, harness, source, manifest), source)]
|
|
220
322
|
|
|
221
323
|
items = []
|
|
222
324
|
for path in sources:
|
|
223
325
|
text = path.read_text(encoding="utf-8")
|
|
326
|
+
out_path = _resolve_output(output_template, path, root, source, project_dir, home_dir)
|
|
224
327
|
if transform == "frontmatter":
|
|
225
328
|
fields = tuple(target.get("fields", FRONTMATTER_WHITELIST))
|
|
226
329
|
content = _transform_frontmatter(
|
|
@@ -230,9 +333,13 @@ def _expand_target(
|
|
|
230
333
|
content = _transform_plugin_json(
|
|
231
334
|
text, harness, str(path.relative_to(root))
|
|
232
335
|
)
|
|
336
|
+
elif transform == "mcp-config":
|
|
337
|
+
content = _transform_mcp_config(
|
|
338
|
+
text, harness, str(path.relative_to(root))
|
|
339
|
+
)
|
|
233
340
|
else:
|
|
234
341
|
content = _transform_copy(text, harness, str(path.relative_to(root)))
|
|
235
|
-
items.append((
|
|
342
|
+
items.append((out_path, content, str(path.relative_to(root))))
|
|
236
343
|
return items
|
|
237
344
|
|
|
238
345
|
|
|
@@ -245,8 +352,9 @@ def generate(
|
|
|
245
352
|
staging: list[tuple[Path, str]] = []
|
|
246
353
|
|
|
247
354
|
for target in manifest["targets"]:
|
|
248
|
-
for
|
|
249
|
-
|
|
355
|
+
for out_path, content, _source in _expand_target(
|
|
356
|
+
root, target, manifest, project_dir, home_dir
|
|
357
|
+
):
|
|
250
358
|
staging.append((out_path, content))
|
|
251
359
|
|
|
252
360
|
# Write via temp files then move (atomic per file).
|
|
@@ -259,10 +367,55 @@ def generate(
|
|
|
259
367
|
return written
|
|
260
368
|
|
|
261
369
|
|
|
370
|
+
def _check_claude_code_sources(root: Path) -> list[str]:
|
|
371
|
+
"""Validate that .claude/ source files referenced by hooks.yaml exist."""
|
|
372
|
+
diffs: list[str] = []
|
|
373
|
+
hooks_yaml = root / HOOKS_SOURCE_PATH
|
|
374
|
+
if not hooks_yaml.exists():
|
|
375
|
+
diffs.append(f"missing: {hooks_yaml}")
|
|
376
|
+
return diffs
|
|
377
|
+
|
|
378
|
+
try:
|
|
379
|
+
hooks_data = yaml.safe_load(hooks_yaml.read_text(encoding="utf-8")) or {}
|
|
380
|
+
except yaml.YAMLError as exc:
|
|
381
|
+
diffs.append(f"invalid YAML: {hooks_yaml}: {exc}")
|
|
382
|
+
return diffs
|
|
383
|
+
|
|
384
|
+
hooks = hooks_data.get("hooks", [])
|
|
385
|
+
if not isinstance(hooks, list):
|
|
386
|
+
diffs.append("invalid hooks.yaml: 'hooks' is not a list")
|
|
387
|
+
return diffs
|
|
388
|
+
|
|
389
|
+
for hook in hooks:
|
|
390
|
+
if not isinstance(hook, dict):
|
|
391
|
+
continue
|
|
392
|
+
file_name = hook.get("file")
|
|
393
|
+
if not file_name:
|
|
394
|
+
continue
|
|
395
|
+
hook_file = root / ".claude" / "hooks" / file_name
|
|
396
|
+
if not hook_file.exists():
|
|
397
|
+
diffs.append(f"missing hook: {hook_file}")
|
|
398
|
+
elif not hook_file.stat().st_mode & 0o111:
|
|
399
|
+
diffs.append(f"not executable: {hook_file}")
|
|
400
|
+
|
|
401
|
+
mcp_yaml = root / MCP_SOURCE_PATH
|
|
402
|
+
if not mcp_yaml.exists():
|
|
403
|
+
diffs.append(f"missing: {mcp_yaml}")
|
|
404
|
+
|
|
405
|
+
risk_policy = root / ".claude" / "policies" / "security.yaml"
|
|
406
|
+
if not risk_policy.exists():
|
|
407
|
+
diffs.append(f"missing: {risk_policy}")
|
|
408
|
+
|
|
409
|
+
return diffs
|
|
410
|
+
|
|
411
|
+
|
|
262
412
|
def check_drift(
|
|
263
413
|
root: Path, harness: str, project_dir: Path, home_dir: Path
|
|
264
414
|
) -> list[str]:
|
|
265
415
|
"""Regenerate into a temp dir and compare with existing artifacts."""
|
|
416
|
+
if harness == "claude-code":
|
|
417
|
+
return _check_claude_code_sources(root)
|
|
418
|
+
|
|
266
419
|
diffs: list[str] = []
|
|
267
420
|
with tempfile.TemporaryDirectory() as tmp:
|
|
268
421
|
tmp_project = Path(tmp) / "project"
|
|
@@ -282,6 +435,19 @@ def check_drift(
|
|
|
282
435
|
return diffs
|
|
283
436
|
|
|
284
437
|
|
|
438
|
+
def _load_dotenv(root: Path) -> None:
|
|
439
|
+
"""Load environment variables from project root .env if python-dotenv is available."""
|
|
440
|
+
env_file = root / ".env"
|
|
441
|
+
if not env_file.exists():
|
|
442
|
+
return
|
|
443
|
+
try:
|
|
444
|
+
from dotenv import load_dotenv
|
|
445
|
+
|
|
446
|
+
load_dotenv(dotenv_path=env_file, override=False, verbose=False)
|
|
447
|
+
except ImportError:
|
|
448
|
+
pass
|
|
449
|
+
|
|
450
|
+
|
|
285
451
|
def main(argv: list[str] | None = None) -> int:
|
|
286
452
|
parser = argparse.ArgumentParser(description="Lincoln harness adapter generator")
|
|
287
453
|
parser.add_argument("--harness", required=True, help="Harness name (codex|opencode|claude-code)")
|
|
@@ -291,6 +457,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
291
457
|
parser.add_argument("--check", action="store_true", help="Drift check only; no writes")
|
|
292
458
|
args = parser.parse_args(argv)
|
|
293
459
|
|
|
460
|
+
_load_dotenv(args.root)
|
|
461
|
+
|
|
294
462
|
try:
|
|
295
463
|
if args.check:
|
|
296
464
|
diffs = check_drift(args.root, args.harness, args.project_dir, args.home_dir)
|
|
@@ -305,6 +473,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
305
473
|
return 1
|
|
306
474
|
print(f"No drift for harness '{args.harness}'.")
|
|
307
475
|
return 0
|
|
476
|
+
if args.harness == "claude-code":
|
|
477
|
+
# Claude Code uses .claude/ directly; nothing to generate.
|
|
478
|
+
print("Harness 'claude-code' uses .claude/ as the source of truth; no artifacts generated.")
|
|
479
|
+
return 0
|
|
308
480
|
written = generate(args.root, args.harness, args.project_dir, args.home_dir)
|
|
309
481
|
print(f"Generated {len(written)} artifacts for harness '{args.harness}':")
|
|
310
482
|
for path in written:
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Lincoln MR / PR knowledge miner.
|
|
3
|
+
|
|
4
|
+
Extracts decision-related review comments from a merged pull request and writes
|
|
5
|
+
structured decision notes to the issue work package. These notes stay in
|
|
6
|
+
issue-<N>/docs/decisions/ until the sync-knowledge stage promotes them to
|
|
7
|
+
knowledge/.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
DECISION_KEYWORDS = ["decision", "decided", "agreed", "rejected", "note:", "approve", "approved"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _run_gh(args: list[str]) -> dict[str, Any] | list[Any]:
|
|
24
|
+
"""Call the gh CLI and return parsed JSON."""
|
|
25
|
+
cmd = ["gh", *args]
|
|
26
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
27
|
+
if result.returncode != 0:
|
|
28
|
+
raise RuntimeError(result.stderr.strip() or "gh CLI failed")
|
|
29
|
+
return json.loads(result.stdout or "[]")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _extract_decision_snippets(text: str) -> list[str]:
|
|
33
|
+
"""Pull decision-oriented sentences or paragraphs from a comment body."""
|
|
34
|
+
if not text:
|
|
35
|
+
return []
|
|
36
|
+
snippets = []
|
|
37
|
+
for paragraph in re.split(r"\n\s*\n", text.strip()):
|
|
38
|
+
paragraph = paragraph.strip()
|
|
39
|
+
if not paragraph:
|
|
40
|
+
continue
|
|
41
|
+
lowered = paragraph.lower()
|
|
42
|
+
if any(keyword in lowered for keyword in DECISION_KEYWORDS):
|
|
43
|
+
snippets.append(paragraph)
|
|
44
|
+
return snippets
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _sanitize_filename(text: str) -> str:
|
|
48
|
+
"""Reduce a comment snippet to a safe filename slug."""
|
|
49
|
+
text = text.lower()
|
|
50
|
+
text = re.sub(r"[^a-z0-9]+", "-", text)
|
|
51
|
+
text = text.strip("-")
|
|
52
|
+
return text[:60]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def mine_pr_decisions(
|
|
56
|
+
owner: str,
|
|
57
|
+
repo: str,
|
|
58
|
+
pr_number: int,
|
|
59
|
+
issue_number: int | None = None,
|
|
60
|
+
output_dir: Path | None = None,
|
|
61
|
+
) -> list[Path]:
|
|
62
|
+
"""Mine decision comments from a PR and write them to the issue package."""
|
|
63
|
+
if output_dir is None:
|
|
64
|
+
if issue_number is None:
|
|
65
|
+
raise ValueError("issue_number or output_dir is required")
|
|
66
|
+
output_dir = Path(f"issue-{issue_number}") / "docs" / "decisions"
|
|
67
|
+
output_dir = Path(output_dir)
|
|
68
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
|
|
70
|
+
comments = _run_gh(
|
|
71
|
+
["api", f"repos/{owner}/{repo}/pulls/{pr_number}/comments"]
|
|
72
|
+
)
|
|
73
|
+
review_comments = _run_gh(
|
|
74
|
+
["api", f"repos/{owner}/{repo}/pulls/{pr_number}/reviews"]
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
if isinstance(comments, dict):
|
|
78
|
+
comments = [comments]
|
|
79
|
+
if isinstance(review_comments, dict):
|
|
80
|
+
review_comments = [review_comments]
|
|
81
|
+
|
|
82
|
+
all_comments = list(comments) + list(review_comments)
|
|
83
|
+
|
|
84
|
+
written: list[Path] = []
|
|
85
|
+
seen: set[str] = set()
|
|
86
|
+
|
|
87
|
+
for comment in all_comments:
|
|
88
|
+
body = comment.get("body") or ""
|
|
89
|
+
snippets = _extract_decision_snippets(body)
|
|
90
|
+
for snippet in snippets:
|
|
91
|
+
if snippet in seen:
|
|
92
|
+
continue
|
|
93
|
+
seen.add(snippet)
|
|
94
|
+
slug = _sanitize_filename(snippet)
|
|
95
|
+
filename = f"pr-{pr_number}-{slug}.md"
|
|
96
|
+
path = output_dir / filename
|
|
97
|
+
if path.exists():
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
content = f"""# PR {pr_number} Decision Note
|
|
101
|
+
|
|
102
|
+
**Source:** {owner}/{repo}#{pr_number}
|
|
103
|
+
**Author:** {comment.get('author', {}).get('login', 'unknown')}
|
|
104
|
+
**Created:** {comment.get('createdAt', 'unknown')}
|
|
105
|
+
**Comment ID:** {comment.get('id', 'unknown')}
|
|
106
|
+
|
|
107
|
+
## Snippet
|
|
108
|
+
|
|
109
|
+
{snippet}
|
|
110
|
+
|
|
111
|
+
## Keywords
|
|
112
|
+
|
|
113
|
+
{', '.join(DECISION_KEYWORDS)}
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
*Auto-generated by scripts/lincoln_mr_mine.py*
|
|
117
|
+
"""
|
|
118
|
+
path.write_text(content, encoding="utf-8")
|
|
119
|
+
written.append(path)
|
|
120
|
+
|
|
121
|
+
return written
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def main(argv: list[str] | None = None) -> int:
|
|
125
|
+
parser = argparse.ArgumentParser(description="Mine PR review decisions")
|
|
126
|
+
parser.add_argument("--owner", required=True)
|
|
127
|
+
parser.add_argument("--repo", required=True)
|
|
128
|
+
parser.add_argument("--pr", type=int, required=True)
|
|
129
|
+
parser.add_argument("--issue", type=int, default=None)
|
|
130
|
+
parser.add_argument("--output-dir", type=Path, default=None)
|
|
131
|
+
args = parser.parse_args(argv)
|
|
132
|
+
|
|
133
|
+
paths = mine_pr_decisions(
|
|
134
|
+
owner=args.owner,
|
|
135
|
+
repo=args.repo,
|
|
136
|
+
pr_number=args.pr,
|
|
137
|
+
issue_number=args.issue,
|
|
138
|
+
output_dir=args.output_dir,
|
|
139
|
+
)
|
|
140
|
+
for path in paths:
|
|
141
|
+
print(path)
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
sys.exit(main())
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Lincoln recall precheck.
|
|
3
|
+
|
|
4
|
+
Given a task description, scores knowledge base documents by keyword overlap
|
|
5
|
+
(and optionally by embedding similarity) and returns the top-K matches.
|
|
6
|
+
Designed to be called from on-session-start.sh before injecting context.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
DEFAULT_KNOWLEDGE_DIR = "knowledge"
|
|
19
|
+
DEFAULT_TOP_K = 5
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _tokenize(text: str) -> set[str]:
|
|
23
|
+
"""Extract lowercase alphanumeric tokens."""
|
|
24
|
+
return set(re.findall(r"[a-zA-Z0-9一-鿿]+", text.lower()))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _load_documents(knowledge_dir: Path) -> list[dict[str, Any]]:
|
|
28
|
+
docs: list[dict[str, Any]] = []
|
|
29
|
+
if not knowledge_dir.exists():
|
|
30
|
+
return docs
|
|
31
|
+
for path in sorted(knowledge_dir.rglob("*.md")):
|
|
32
|
+
try:
|
|
33
|
+
text = path.read_text(encoding="utf-8")
|
|
34
|
+
docs.append(
|
|
35
|
+
{
|
|
36
|
+
"path": str(path.relative_to(knowledge_dir)),
|
|
37
|
+
"tokens": _tokenize(text),
|
|
38
|
+
"title": path.stem,
|
|
39
|
+
"_text": text[:2000],
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
except Exception:
|
|
43
|
+
continue
|
|
44
|
+
return docs
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _score_by_keywords(query_tokens: set[str], doc: dict[str, Any]) -> float:
|
|
48
|
+
if not query_tokens:
|
|
49
|
+
return 0.0
|
|
50
|
+
doc_tokens = doc["tokens"]
|
|
51
|
+
if not doc_tokens:
|
|
52
|
+
return 0.0
|
|
53
|
+
overlap = len(query_tokens & doc_tokens)
|
|
54
|
+
return overlap / max(len(query_tokens), len(doc_tokens))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _try_embedding_score(query: str, docs: list[dict[str, Any]]) -> list[float] | None:
|
|
58
|
+
"""Attempt embedding-based scoring if sentence-transformers is installed."""
|
|
59
|
+
try:
|
|
60
|
+
from sentence_transformers import SentenceTransformer, util # type: ignore
|
|
61
|
+
|
|
62
|
+
model = SentenceTransformer("all-MiniLM-L6-v2")
|
|
63
|
+
query_embedding = model.encode(query, convert_to_tensor=True)
|
|
64
|
+
doc_texts = [doc.get("_text", "") for doc in docs]
|
|
65
|
+
doc_embeddings = model.encode(doc_texts, convert_to_tensor=True)
|
|
66
|
+
scores = util.cos_sim(query_embedding, doc_embeddings)[0].tolist()
|
|
67
|
+
return list(scores)
|
|
68
|
+
except Exception:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def recall(
|
|
73
|
+
query: str,
|
|
74
|
+
knowledge_dir: Path | None = None,
|
|
75
|
+
top_k: int = DEFAULT_TOP_K,
|
|
76
|
+
use_embedding: bool = True,
|
|
77
|
+
) -> dict[str, Any]:
|
|
78
|
+
"""Return top-K knowledge documents for a query."""
|
|
79
|
+
knowledge_dir = knowledge_dir or Path(DEFAULT_KNOWLEDGE_DIR)
|
|
80
|
+
docs = _load_documents(knowledge_dir)
|
|
81
|
+
query_tokens = _tokenize(query)
|
|
82
|
+
|
|
83
|
+
if use_embedding:
|
|
84
|
+
embedding_scores = _try_embedding_score(query, docs)
|
|
85
|
+
else:
|
|
86
|
+
embedding_scores = None
|
|
87
|
+
|
|
88
|
+
results = []
|
|
89
|
+
for idx, doc in enumerate(docs):
|
|
90
|
+
keyword_score = _score_by_keywords(query_tokens, doc)
|
|
91
|
+
if embedding_scores is not None:
|
|
92
|
+
score = 0.5 * keyword_score + 0.5 * embedding_scores[idx]
|
|
93
|
+
else:
|
|
94
|
+
score = keyword_score
|
|
95
|
+
results.append(
|
|
96
|
+
{
|
|
97
|
+
"path": doc["path"],
|
|
98
|
+
"title": doc["title"],
|
|
99
|
+
"score": round(score, 4),
|
|
100
|
+
"method": "embedding+keywords" if embedding_scores else "keywords",
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
results.sort(key=lambda r: r["score"], reverse=True)
|
|
105
|
+
return {
|
|
106
|
+
"query": query,
|
|
107
|
+
"top_k": top_k,
|
|
108
|
+
"docs": [r for r in results[:top_k] if r["score"] > 0],
|
|
109
|
+
"method": "embedding+keywords" if embedding_scores else "keywords",
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def main(argv: list[str] | None = None) -> int:
|
|
114
|
+
parser = argparse.ArgumentParser(description="Recall relevant knowledge documents")
|
|
115
|
+
parser.add_argument("--query", required=True)
|
|
116
|
+
parser.add_argument("--knowledge-dir", type=Path, default=None)
|
|
117
|
+
parser.add_argument("--top-k", type=int, default=DEFAULT_TOP_K)
|
|
118
|
+
parser.add_argument("--format", choices=["json", "markdown"], default="json")
|
|
119
|
+
parser.add_argument("--no-embedding", action="store_true", default=False)
|
|
120
|
+
args = parser.parse_args(argv)
|
|
121
|
+
|
|
122
|
+
result = recall(
|
|
123
|
+
query=args.query,
|
|
124
|
+
knowledge_dir=args.knowledge_dir,
|
|
125
|
+
top_k=args.top_k,
|
|
126
|
+
use_embedding=not args.no_embedding,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
if args.format == "json":
|
|
130
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
131
|
+
else:
|
|
132
|
+
print(f"# 相关知识({result['method']})\n")
|
|
133
|
+
if result["docs"]:
|
|
134
|
+
for doc in result["docs"]:
|
|
135
|
+
print(f"- [{doc['title']}]({doc['path']}) — score {doc['score']}")
|
|
136
|
+
else:
|
|
137
|
+
print("_未找到相关知识。_")
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
if __name__ == "__main__":
|
|
142
|
+
sys.exit(main())
|