@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.
Files changed (45) hide show
  1. package/framework/.claude/agents/pm.md +2 -2
  2. package/framework/.claude/agents/security-reviewer.md +57 -0
  3. package/framework/.claude/harnesses/codex.yaml +31 -2
  4. package/framework/.claude/harnesses/command-map.yaml +4 -0
  5. package/framework/.claude/harnesses/opencode.yaml +33 -3
  6. package/framework/.claude/hooks/hooks.yaml +37 -0
  7. package/framework/.claude/hooks/on-session-start.sh +20 -5
  8. package/framework/.claude/hooks/on-stop.sh +22 -0
  9. package/framework/.claude/hooks/pre-tool-use.sh +22 -0
  10. package/framework/.claude/mcp/mcp.yaml +34 -0
  11. package/framework/.claude/policies/friction.yaml +18 -0
  12. package/framework/.claude/policies/security.yaml +46 -0
  13. package/framework/.claude/schemas/design-review-feedback.json +32 -0
  14. package/framework/.claude/schemas/stage-definition.schema.json +22 -0
  15. package/framework/.claude/skills/clarify-requirements/SKILL.md +1 -0
  16. package/framework/.claude/skills/clarify-requirements/prompts/main.md +44 -10
  17. package/framework/.claude/stages/clarify.yaml +3 -1
  18. package/framework/.claude/stages/phase-1-p0-design.yaml +44 -0
  19. package/framework/.claude/stages/phase-1-p0-implement.yaml +47 -0
  20. package/framework/.claude/stages/phase-2-p1-design.yaml +53 -0
  21. package/framework/.claude/stages/phase-2-p1-implement.yaml +45 -0
  22. package/framework/.claude/workflows/framework-self-improvement.yaml +100 -0
  23. package/framework/.claude/workflows/interview-to-knowledge.yaml +1 -0
  24. package/framework/.claude-plugin/marketplace.json +2 -2
  25. package/framework/.claude-plugin/plugin.json +1 -1
  26. package/framework/.version-bump.json +1 -1
  27. package/framework/README.en.md +24 -5
  28. package/framework/README.md +24 -5
  29. package/framework/RELEASE.md +22 -0
  30. package/framework/SKILL.md +15 -6
  31. package/framework/manifest.json +93 -37
  32. package/framework/scripts/check-harness-drift.sh +11 -1
  33. package/framework/scripts/lincoln-setup.py +17 -0
  34. package/framework/scripts/lincoln_condenser.py +110 -0
  35. package/framework/scripts/lincoln_friction.py +266 -0
  36. package/framework/scripts/lincoln_harness_adapter.py +185 -13
  37. package/framework/scripts/lincoln_mr_mine.py +146 -0
  38. package/framework/scripts/lincoln_recall.py +142 -0
  39. package/framework/scripts/lincoln_trace.py +119 -3
  40. package/framework/scripts/security_analyzer.py +234 -0
  41. package/framework/scripts/stage_loader.py +124 -1
  42. package/framework/tools/lincoln/package-lock.json +2 -2
  43. package/framework/tools/lincoln/package.json +1 -1
  44. package/framework/tools/lincoln-installer/package.json +6 -1
  45. 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) from
5
- the single source of truth in `.claude/`, driven by `.claude/harnesses/<name>.yaml`
6
- manifests. Idempotent; `--check` regenerates into a temp dir and reports drift.
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 _resolve_output(template: str, name: str, project_dir: Path, home_dir: Path) -> Path:
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), home=str(home_dir), name=name
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, target: dict[str, Any], manifest: dict[str, Any]
199
- ) -> list[tuple[str, str, str]]:
200
- """Expand a target into (output_name, content, source_label) tuples."""
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
- items.append((cmd_name, content, "command_map"))
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
- return [("__concat__", _transform_concat(texts, harness, source, manifest), source)]
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((path.stem, content, str(path.relative_to(root))))
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 name, content, _source in _expand_target(root, target, manifest):
249
- out_path = _resolve_output(target["output"], name, project_dir, home_dir)
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: