@sushanglewis/lincoln 1.6.3 → 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 +44 -10
- package/framework/.claude/stages/clarify.yaml +3 -1
- 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/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 +22 -0
- package/framework/SKILL.md +15 -6
- package/framework/manifest.json +93 -37
- 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_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
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Lincoln friction scorer.
|
|
3
|
+
|
|
4
|
+
Reads the session trace for the current node, computes a friction score based
|
|
5
|
+
on configured signals, and writes a suggestion/prompt file when thresholds are
|
|
6
|
+
met. Designed to be invoked asynchronously from on-stop.sh.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import yaml
|
|
20
|
+
|
|
21
|
+
DEFAULT_POLICY_PATH = ".claude/policies/friction.yaml"
|
|
22
|
+
DEFAULT_TRACE_TAIL_LINES = 500
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_policy(root: Path) -> dict[str, Any]:
|
|
26
|
+
"""Load the friction policy YAML, returning defaults if missing."""
|
|
27
|
+
path = root / DEFAULT_POLICY_PATH
|
|
28
|
+
if not path.exists():
|
|
29
|
+
return _default_policy()
|
|
30
|
+
return yaml.safe_load(path.read_text(encoding="utf-8")) or _default_policy()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _default_policy() -> dict[str, Any]:
|
|
34
|
+
return {
|
|
35
|
+
"schema_version": "1.0.0",
|
|
36
|
+
"thresholds": {"prompt_user": 5, "auto_suggest": 3},
|
|
37
|
+
"weights": {
|
|
38
|
+
"tool_failed": 2,
|
|
39
|
+
"tool_rejected": 3,
|
|
40
|
+
"retry_spike": 3,
|
|
41
|
+
"human_override": 5,
|
|
42
|
+
"long_session": 1,
|
|
43
|
+
},
|
|
44
|
+
"long_session_minutes": 30,
|
|
45
|
+
"retry_count_threshold": 3,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _tail_jsonl(trace_file: Path, lines: int) -> list[dict[str, Any]]:
|
|
50
|
+
if not trace_file.exists():
|
|
51
|
+
return []
|
|
52
|
+
try:
|
|
53
|
+
with open(trace_file, "r", encoding="utf-8") as fh:
|
|
54
|
+
all_lines = fh.readlines()
|
|
55
|
+
return [json.loads(line) for line in all_lines[-lines:] if line.strip()]
|
|
56
|
+
except Exception:
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _parse_timestamp(ts: str | None) -> datetime | None:
|
|
61
|
+
if not ts:
|
|
62
|
+
return None
|
|
63
|
+
try:
|
|
64
|
+
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
65
|
+
except ValueError:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _compute_long_session_minutes(events: list[dict[str, Any]], threshold_minutes: int) -> float:
|
|
70
|
+
if len(events) < 2 or threshold_minutes <= 0:
|
|
71
|
+
return 0.0
|
|
72
|
+
timestamps = [t for t in (_parse_timestamp(e.get("timestamp")) for e in events) if t]
|
|
73
|
+
if len(timestamps) < 2:
|
|
74
|
+
return 0.0
|
|
75
|
+
duration = (max(timestamps) - min(timestamps)).total_seconds() / 60.0
|
|
76
|
+
return duration if duration >= threshold_minutes else 0.0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _count_tool_failures(events: list[dict[str, Any]]) -> int:
|
|
80
|
+
return sum(
|
|
81
|
+
1
|
|
82
|
+
for e in events
|
|
83
|
+
if e.get("category") == "tool" and e.get("exit_code", 0) != 0
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _count_tool_rejected(events: list[dict[str, Any]]) -> int:
|
|
88
|
+
return sum(
|
|
89
|
+
1
|
|
90
|
+
for e in events
|
|
91
|
+
if e.get("category") == "tool" and e.get("args_summary", {}).get("blocked") is True
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _count_retry_spikes(events: list[dict[str, Any]], threshold: int) -> int:
|
|
96
|
+
"""Count tools that consecutively failed >= threshold times in this window."""
|
|
97
|
+
if threshold <= 0:
|
|
98
|
+
return 0
|
|
99
|
+
counts: dict[str, int] = {}
|
|
100
|
+
spikes = 0
|
|
101
|
+
for e in events:
|
|
102
|
+
if e.get("category") != "tool":
|
|
103
|
+
continue
|
|
104
|
+
tool = e.get("tool", "")
|
|
105
|
+
failed = e.get("exit_code", 0) != 0 or e.get("args_summary", {}).get("blocked") is True
|
|
106
|
+
if failed:
|
|
107
|
+
counts[tool] = counts.get(tool, 0) + 1
|
|
108
|
+
if counts[tool] == threshold:
|
|
109
|
+
spikes += 1
|
|
110
|
+
else:
|
|
111
|
+
counts[tool] = 0
|
|
112
|
+
return spikes
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def compute_friction(
|
|
116
|
+
events: list[dict[str, Any]],
|
|
117
|
+
policy: dict[str, Any],
|
|
118
|
+
) -> dict[str, Any]:
|
|
119
|
+
"""Compute friction score and signals from a window of trace events."""
|
|
120
|
+
weights = policy.get("weights", {})
|
|
121
|
+
retry_threshold = policy.get("retry_count_threshold", 3)
|
|
122
|
+
long_session_minutes = policy.get("long_session_minutes", 30)
|
|
123
|
+
|
|
124
|
+
tool_failed = _count_tool_failures(events)
|
|
125
|
+
tool_rejected = _count_tool_rejected(events)
|
|
126
|
+
retry_spike = _count_retry_spikes(events, retry_threshold)
|
|
127
|
+
long_session = _compute_long_session_minutes(events, long_session_minutes)
|
|
128
|
+
|
|
129
|
+
signals: dict[str, Any] = {
|
|
130
|
+
"tool_failed": {"count": tool_failed, "weight": weights.get("tool_failed", 0)},
|
|
131
|
+
"tool_rejected": {"count": tool_rejected, "weight": weights.get("tool_rejected", 0)},
|
|
132
|
+
"retry_spike": {"count": retry_spike, "weight": weights.get("retry_spike", 0)},
|
|
133
|
+
"human_override": {"count": 0, "weight": weights.get("human_override", 0)},
|
|
134
|
+
"long_session": {
|
|
135
|
+
"minutes": round(long_session, 2),
|
|
136
|
+
"weight": weights.get("long_session", 0),
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
score = (
|
|
141
|
+
tool_failed * signals["tool_failed"]["weight"]
|
|
142
|
+
+ tool_rejected * signals["tool_rejected"]["weight"]
|
|
143
|
+
+ retry_spike * signals["retry_spike"]["weight"]
|
|
144
|
+
+ signals["human_override"]["count"] * signals["human_override"]["weight"]
|
|
145
|
+
+ (1 if long_session >= long_session_minutes else 0) * signals["long_session"]["weight"]
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
"score": score,
|
|
150
|
+
"signals": signals,
|
|
151
|
+
"event_count": len(events),
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def resolve_trace_file(state_path: Path | None) -> Path | None:
|
|
156
|
+
"""Resolve the trace file from workflow state or environment override."""
|
|
157
|
+
env_file = os.environ.get("LINCOLN_TRACE_FILE")
|
|
158
|
+
if env_file:
|
|
159
|
+
return Path(env_file)
|
|
160
|
+
|
|
161
|
+
if state_path is None:
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
sys.path.insert(0, str(state_path.resolve().parents[1]))
|
|
166
|
+
from scripts.lincoln_paths import get_process_slug, process_package_root
|
|
167
|
+
from scripts.lincoln_trace import _load_state
|
|
168
|
+
|
|
169
|
+
state = _load_state(state_path)
|
|
170
|
+
if state is None:
|
|
171
|
+
return state_path.parent / ".trace" / "lc-trace.jsonl"
|
|
172
|
+
slug = get_process_slug(state, state_path)
|
|
173
|
+
root = process_package_root(slug, state, state_path)
|
|
174
|
+
return root / ".trace" / "lc-trace.jsonl"
|
|
175
|
+
except Exception:
|
|
176
|
+
return state_path.parent / ".trace" / "lc-trace.jsonl"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def write_friction_report(
|
|
180
|
+
trace_dir: Path,
|
|
181
|
+
score: int,
|
|
182
|
+
signals: dict[str, Any],
|
|
183
|
+
policy: dict[str, Any],
|
|
184
|
+
) -> Path | None:
|
|
185
|
+
"""Write a friction suggestion or prompt file when thresholds are met."""
|
|
186
|
+
thresholds = policy.get("thresholds", {})
|
|
187
|
+
prompt_threshold = thresholds.get("prompt_user", 5)
|
|
188
|
+
suggest_threshold = thresholds.get("auto_suggest", 3)
|
|
189
|
+
|
|
190
|
+
if score >= prompt_threshold:
|
|
191
|
+
path = trace_dir / "friction-prompt.md"
|
|
192
|
+
kind = "prompt"
|
|
193
|
+
elif score >= suggest_threshold:
|
|
194
|
+
path = trace_dir / "friction-suggestion.md"
|
|
195
|
+
kind = "suggestion"
|
|
196
|
+
else:
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
active_signals = [
|
|
200
|
+
f"- **{name}**: {info.get('count', info.get('minutes', 0))} (weight {info['weight']})"
|
|
201
|
+
for name, info in signals.items()
|
|
202
|
+
if (info.get("count", 0) > 0 or info.get("minutes", 0) > 0)
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
content = f"""# Friction Report ({kind})
|
|
206
|
+
|
|
207
|
+
Generated at: {datetime.now(timezone.utc).isoformat()}
|
|
208
|
+
Score: **{score}**
|
|
209
|
+
|
|
210
|
+
## Detected signals
|
|
211
|
+
|
|
212
|
+
{chr(10).join(active_signals) if active_signals else "_No strong friction signals detected._"}
|
|
213
|
+
|
|
214
|
+
## Suggested action
|
|
215
|
+
|
|
216
|
+
Consider capturing a lesson learned in `knowledge/05-learnings/` or running:
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
python3 scripts/lincoln-knowledge.py suggest --stage <stage_id>
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
This is a suggestion only; the human PM decides whether to persist anything.
|
|
223
|
+
"""
|
|
224
|
+
path.write_text(content, encoding="utf-8")
|
|
225
|
+
return path
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def score_friction(
|
|
229
|
+
root: Path,
|
|
230
|
+
state_path: Path | None = None,
|
|
231
|
+
trace_file: Path | None = None,
|
|
232
|
+
tail_lines: int = DEFAULT_TRACE_TAIL_LINES,
|
|
233
|
+
) -> dict[str, Any]:
|
|
234
|
+
"""Score friction and write a report if thresholds are met."""
|
|
235
|
+
policy = load_policy(root)
|
|
236
|
+
target_trace = trace_file or resolve_trace_file(state_path)
|
|
237
|
+
if target_trace is None:
|
|
238
|
+
return {"score": 0, "signals": {}, "report": None, "error": "no trace file"}
|
|
239
|
+
|
|
240
|
+
events = _tail_jsonl(target_trace, tail_lines)
|
|
241
|
+
result = compute_friction(events, policy)
|
|
242
|
+
report = write_friction_report(target_trace.parent, result["score"], result["signals"], policy)
|
|
243
|
+
result["report"] = str(report) if report else None
|
|
244
|
+
return result
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def main(argv: list[str] | None = None) -> int:
|
|
248
|
+
parser = argparse.ArgumentParser(description="Score session friction from trace events")
|
|
249
|
+
parser.add_argument("--root", type=Path, default=Path.cwd())
|
|
250
|
+
parser.add_argument("--state-file", type=Path, default=None)
|
|
251
|
+
parser.add_argument("--trace-file", type=Path, default=None)
|
|
252
|
+
parser.add_argument("--tail-lines", type=int, default=DEFAULT_TRACE_TAIL_LINES)
|
|
253
|
+
args = parser.parse_args(argv)
|
|
254
|
+
|
|
255
|
+
result = score_friction(
|
|
256
|
+
root=args.root,
|
|
257
|
+
state_path=args.state_file,
|
|
258
|
+
trace_file=args.trace_file,
|
|
259
|
+
tail_lines=args.tail_lines,
|
|
260
|
+
)
|
|
261
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
262
|
+
return 0
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
if __name__ == "__main__":
|
|
266
|
+
sys.exit(main())
|
|
@@ -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:
|