@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.
Files changed (54) 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 +83 -80
  17. package/framework/.claude/skills/draft-product-design/prompts/main.md +27 -14
  18. package/framework/.claude/stages/clarify.yaml +7 -5
  19. package/framework/.claude/stages/phase-1-p0-design.yaml +44 -0
  20. package/framework/.claude/stages/phase-1-p0-implement.yaml +47 -0
  21. package/framework/.claude/stages/phase-2-p1-design.yaml +53 -0
  22. package/framework/.claude/stages/phase-2-p1-implement.yaml +45 -0
  23. package/framework/.claude/stages/product-design-docs.yaml +6 -6
  24. package/framework/.claude/workflows/framework-self-improvement.yaml +100 -0
  25. package/framework/.claude/workflows/interview-to-knowledge.yaml +1 -0
  26. package/framework/.claude-plugin/marketplace.json +2 -2
  27. package/framework/.claude-plugin/plugin.json +1 -1
  28. package/framework/.version-bump.json +1 -1
  29. package/framework/README.en.md +24 -5
  30. package/framework/README.md +24 -5
  31. package/framework/RELEASE.md +41 -0
  32. package/framework/SKILL.md +15 -6
  33. package/framework/manifest.json +96 -56
  34. package/framework/scripts/check-harness-drift.sh +11 -1
  35. package/framework/scripts/lincoln-setup.py +17 -0
  36. package/framework/scripts/lincoln_condenser.py +110 -0
  37. package/framework/scripts/lincoln_friction.py +266 -0
  38. package/framework/scripts/lincoln_harness_adapter.py +185 -13
  39. package/framework/scripts/lincoln_mr_mine.py +146 -0
  40. package/framework/scripts/lincoln_recall.py +142 -0
  41. package/framework/scripts/lincoln_render.py +209 -6
  42. package/framework/scripts/lincoln_trace.py +119 -3
  43. package/framework/scripts/security_analyzer.py +234 -0
  44. package/framework/scripts/stage_loader.py +124 -1
  45. package/framework/tools/lincoln/package-lock.json +2 -2
  46. package/framework/tools/lincoln/package.json +1 -1
  47. package/framework/tools/lincoln-installer/package.json +6 -1
  48. package/package.json +6 -1
  49. package/framework/.claude/agents/external/everything-claude-code/agents/ecc-tdd-guide.md +0 -108
  50. package/framework/.claude/agents/external/oh-my-claudecode/agents/omc-architect.md +0 -134
  51. package/framework/.claude/agents/external/oh-my-claudecode/agents/omc-code-reviewer.md +0 -248
  52. package/framework/.claude/agents/external/oh-my-claudecode/agents/omc-designer.md +0 -122
  53. package/framework/.claude/agents/external/oh-my-claudecode/agents/omc-qa-tester.md +0 -106
  54. package/framework/.claude/agents/external/wshobson-agents/agents/wshobson-frontend-developer.md +0 -173
@@ -583,6 +583,20 @@ def _add_common_args(p: argparse.ArgumentParser) -> None:
583
583
  )
584
584
 
585
585
 
586
+
587
+ def _load_dotenv(root: Path) -> None:
588
+ """Load environment variables from project root .env if python-dotenv is available."""
589
+ env_file = root / ".env"
590
+ if not env_file.exists():
591
+ return
592
+ try:
593
+ from dotenv import load_dotenv
594
+
595
+ load_dotenv(dotenv_path=env_file, override=False, verbose=False)
596
+ except ImportError:
597
+ # python-dotenv is optional; callers can export vars manually.
598
+ pass
599
+
586
600
  def main(args: list[str] | None = None) -> int:
587
601
  parser = argparse.ArgumentParser(
588
602
  prog="lc-setup",
@@ -632,6 +646,9 @@ def main(args: list[str] | None = None) -> int:
632
646
 
633
647
  parsed = parser.parse_args(args)
634
648
 
649
+ # Make .env variables available to install commands and harness adapters.
650
+ _load_dotenv(Path(parsed.root).resolve())
651
+
635
652
  handlers = {
636
653
  "check": run_check,
637
654
  "install-skills": run_install_skills,
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env python3
2
+ """Lincoln trace condenser.
3
+
4
+ Summarizes a window of trace events when the count exceeds a threshold. The
5
+ summary is appended as a `condensation` event to the trace and returned to the
6
+ caller (stage_loader handoff-report) for inclusion in handoff frontmatter.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import sys
14
+ from collections import Counter
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ _PROJECT_ROOT = Path(__file__).resolve().parents[1]
19
+ if str(_PROJECT_ROOT) not in sys.path:
20
+ sys.path.insert(0, str(_PROJECT_ROOT))
21
+
22
+ from scripts import lincoln_trace
23
+
24
+ DEFAULT_THRESHOLD = 200
25
+
26
+
27
+ def load_events(trace_file: Path, max_events: int = 10000) -> list[dict[str, Any]]:
28
+ if not trace_file.exists():
29
+ return []
30
+ try:
31
+ with open(trace_file, "r", encoding="utf-8") as fh:
32
+ lines = fh.readlines()
33
+ return [json.loads(line) for line in lines[-max_events:] if line.strip()]
34
+ except Exception:
35
+ return []
36
+
37
+
38
+ def summarize(events: list[dict[str, Any]]) -> dict[str, Any]:
39
+ categories = Counter(e.get("category", "other") for e in events)
40
+ tools = Counter(e.get("tool", "") for e in events if e.get("category") == "tool")
41
+ stages = Counter(e.get("stage", "unknown") for e in events)
42
+ failed = sum(1 for e in events if e.get("exit_code", 0) != 0)
43
+ blocked = sum(
44
+ 1 for e in events if e.get("args_summary", {}).get("blocked") is True
45
+ )
46
+
47
+ return {
48
+ "event_count": len(events),
49
+ "categories": dict(categories.most_common(10)),
50
+ "top_tools": dict(tools.most_common(10)),
51
+ "stages": dict(stages.most_common(10)),
52
+ "failed_count": failed,
53
+ "blocked_count": blocked,
54
+ "first_timestamp": events[0].get("timestamp") if events else None,
55
+ "last_timestamp": events[-1].get("timestamp") if events else None,
56
+ }
57
+
58
+
59
+ def condense(
60
+ trace_file: Path,
61
+ threshold: int = DEFAULT_THRESHOLD,
62
+ dry_run: bool = False,
63
+ ) -> dict[str, Any] | None:
64
+ """Condense trace events if threshold is exceeded.
65
+
66
+ Returns the condensation summary or None when below threshold.
67
+ """
68
+ events = load_events(trace_file)
69
+ if len(events) < threshold:
70
+ return None
71
+
72
+ summary = summarize(events)
73
+ if not dry_run:
74
+ try:
75
+ lincoln_trace.append_event_to_file(
76
+ trace_file=trace_file,
77
+ category="condensation",
78
+ payload={
79
+ "event_type": "condensation",
80
+ "event_count": summary["event_count"],
81
+ "threshold": threshold,
82
+ "summary": summary,
83
+ },
84
+ run_id=events[-1].get("run_id") if events else None,
85
+ stage=events[-1].get("stage") if events else None,
86
+ node_id=events[-1].get("node_id") if events else None,
87
+ )
88
+ except Exception:
89
+ # Condensation must never block handoff report generation.
90
+ pass
91
+ return summary
92
+
93
+
94
+ def main(argv: list[str] | None = None) -> int:
95
+ parser = argparse.ArgumentParser(description="Condense Lincoln trace events")
96
+ parser.add_argument("--trace-file", type=Path, required=True)
97
+ parser.add_argument("--threshold", type=int, default=DEFAULT_THRESHOLD)
98
+ parser.add_argument("--dry-run", action="store_true", default=False)
99
+ args = parser.parse_args(argv)
100
+
101
+ result = condense(args.trace_file, threshold=args.threshold, dry_run=args.dry_run)
102
+ if result is None:
103
+ print(json.dumps({"condensed": False}, ensure_ascii=False))
104
+ else:
105
+ print(json.dumps({"condensed": True, "summary": result}, ensure_ascii=False, indent=2))
106
+ return 0
107
+
108
+
109
+ if __name__ == "__main__":
110
+ sys.exit(main())
@@ -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())