@sushanglewis/lincoln 1.6.3 → 1.8.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 (55) 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/build-product-prototype/prompts/main.md +6 -1
  16. package/framework/.claude/skills/clarify-requirements/SKILL.md +1 -0
  17. package/framework/.claude/skills/clarify-requirements/prompts/main.md +50 -10
  18. package/framework/.claude/skills/draft-product-design/SKILL.md +1 -0
  19. package/framework/.claude/skills/draft-product-design/prompts/main.md +26 -3
  20. package/framework/.claude/skills/lc-workflow/validators/validate.py +1 -0
  21. package/framework/.claude/stages/clarify.yaml +3 -1
  22. package/framework/.claude/stages/phase-1-p0-design.yaml +44 -0
  23. package/framework/.claude/stages/phase-1-p0-implement.yaml +47 -0
  24. package/framework/.claude/stages/phase-2-p1-design.yaml +53 -0
  25. package/framework/.claude/stages/phase-2-p1-implement.yaml +45 -0
  26. package/framework/.claude/stages/product-design-docs.yaml +2 -0
  27. package/framework/.claude/workflows/bug-fix.yaml +1 -0
  28. package/framework/.claude/workflows/design-spike.yaml +1 -0
  29. package/framework/.claude/workflows/existing-project-iteration.yaml +1 -0
  30. package/framework/.claude/workflows/framework-self-improvement.yaml +100 -0
  31. package/framework/.claude/workflows/interview-to-knowledge.yaml +2 -0
  32. package/framework/.claude/workflows/oss-first-design.yaml +1 -0
  33. package/framework/.claude-plugin/marketplace.json +2 -2
  34. package/framework/.claude-plugin/plugin.json +1 -1
  35. package/framework/.version-bump.json +1 -1
  36. package/framework/README.en.md +43 -6
  37. package/framework/README.md +43 -6
  38. package/framework/RELEASE.md +42 -0
  39. package/framework/SKILL.md +15 -6
  40. package/framework/manifest.json +103 -47
  41. package/framework/scripts/check-harness-drift.sh +11 -1
  42. package/framework/scripts/lincoln-setup.py +17 -0
  43. package/framework/scripts/lincoln_condenser.py +110 -0
  44. package/framework/scripts/lincoln_friction.py +266 -0
  45. package/framework/scripts/lincoln_harness_adapter.py +185 -13
  46. package/framework/scripts/lincoln_index.py +8 -1
  47. package/framework/scripts/lincoln_mr_mine.py +146 -0
  48. package/framework/scripts/lincoln_recall.py +142 -0
  49. package/framework/scripts/lincoln_trace.py +119 -3
  50. package/framework/scripts/security_analyzer.py +234 -0
  51. package/framework/scripts/stage_loader.py +124 -1
  52. package/framework/tools/lincoln/package-lock.json +2 -2
  53. package/framework/tools/lincoln/package.json +1 -1
  54. package/framework/tools/lincoln-installer/package.json +6 -1
  55. package/package.json +6 -1
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env python3
2
+ """Risk analyzer for Lincoln pre-tool-use security gate.
3
+
4
+ Loads `.claude/policies/security.yaml` and evaluates a tool call against the
5
+ configured policies. Designed to be invoked from `.claude/hooks/pre-tool-use.sh`
6
+ and from generated harness-specific hook scripts.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import yaml
19
+
20
+ DEFAULT_POLICY_PATH = ".claude/policies/security.yaml"
21
+
22
+
23
+ def load_policy(root: Path) -> dict[str, Any]:
24
+ """Load the risk policy YAML, returning an empty policy if missing."""
25
+ path = root / DEFAULT_POLICY_PATH
26
+ if not path.exists():
27
+ return {"policies": []}
28
+ return yaml.safe_load(path.read_text(encoding="utf-8")) or {"policies": []}
29
+
30
+
31
+ def _normalize_command(command: str) -> str:
32
+ """Strip leading whitespace and collapse internal spaces for matching."""
33
+ return re.sub(r"\s+", " ", command.strip())
34
+
35
+
36
+ def _split_command_segments(command: str) -> list[str]:
37
+ """Split a shell command on chain operators so each segment is matched.
38
+
39
+ Handles `&&`, `||`, `;`, and `|`.
40
+ """
41
+ segments = re.split(r"\s*(&&|\|\||;|\|)\s*", command)
42
+ return [_normalize_command(seg) for seg in segments if seg and not re.match(r"^(&&|\|\||;|\|)$", seg.strip())]
43
+
44
+
45
+ def _strip_command_prefix(segment: str) -> str:
46
+ """Remove common leading prefixes that should not hide the real command.
47
+
48
+ Examples: `sudo rm foo`, `env VAR=1 rm foo`, `cd /tmp && rm foo`.
49
+ """
50
+ segment = _normalize_command(segment)
51
+ while re.match(r"^[A-Za-z_][A-Za-z0-9_]*=\S+\s+", segment):
52
+ segment = _normalize_command(re.sub(r"^[A-Za-z_][A-Za-z0-9_]*=\S+\s+", "", segment))
53
+ if re.match(r"^(sudo|doas)\s+", segment):
54
+ segment = _normalize_command(re.sub(r"^(sudo|doas)\s+", "", segment))
55
+ if re.match(r"^cd\s+\S+\s*(&&|;|\|)", segment):
56
+ segment = _normalize_command(re.sub(r"^cd\s+\S+\s*(&&|;|\|)", "", segment))
57
+ return segment
58
+
59
+
60
+ def _command_matches(condition: str, command: str) -> bool:
61
+ """Evaluate a human-readable condition against a bash command."""
62
+ lowered = condition.lower()
63
+ segments = _split_command_segments(command)
64
+
65
+ def _any_segment(pattern: str) -> bool:
66
+ return any(re.search(pattern, _strip_command_prefix(seg)) for seg in segments)
67
+
68
+ if "rm -rf" in lowered or 'command matches "rm -rf"' in lowered:
69
+ if _any_segment(r"\brm\s+-rf\b"):
70
+ return True
71
+
72
+ if 'command matches "rm -r" or "rm -rf"' in lowered:
73
+ if _any_segment(r"\brm\s+-r(?:f)?\b"):
74
+ return True
75
+
76
+ if 'command matches any of ["rm", "rmdir"]' in lowered:
77
+ if _any_segment(r"(?:^|\s)(?:rm\b|rmdir\b)"):
78
+ return True
79
+
80
+ if 'command matches "git push"' in lowered:
81
+ if _any_segment(r"\bgit\s+push\b"):
82
+ return True
83
+
84
+ if "external http client" in lowered or "curl, wget" in lowered:
85
+ if _any_segment(r"\b(?:curl|wget|httpie)\b"):
86
+ return True
87
+
88
+ if 'command matches "rm"' in lowered and "rmdir" not in lowered:
89
+ if _any_segment(r"\brm\b"):
90
+ return True
91
+
92
+ return False
93
+
94
+
95
+ def _resolve_target_path(target: str, root: Path) -> Path:
96
+ """Resolve a Write/Edit target to an absolute, canonical path."""
97
+ path = Path(target)
98
+ if not path.is_absolute():
99
+ path = root / path
100
+ try:
101
+ return path.resolve()
102
+ except (OSError, ValueError):
103
+ return path.absolute()
104
+
105
+
106
+ def _target_matches(condition: str, target: str, process_slug: str, root: Path) -> bool:
107
+ """Evaluate a file-target condition using canonical absolute paths."""
108
+ lowered = condition.lower()
109
+ resolved = _resolve_target_path(target, root)
110
+ try:
111
+ package_root = (root / process_slug).resolve()
112
+ except (OSError, ValueError):
113
+ package_root = (root / process_slug).absolute()
114
+
115
+ if "outside current process_slug" in lowered:
116
+ try:
117
+ resolved.relative_to(package_root)
118
+ return False
119
+ except ValueError:
120
+ return True
121
+
122
+ if "inside current process_slug" in lowered:
123
+ try:
124
+ resolved.relative_to(package_root)
125
+ return True
126
+ except ValueError:
127
+ return False
128
+
129
+ return False
130
+
131
+
132
+ def _evaluate_policy(policy: dict[str, Any], tool_name: str, tool_args: dict[str, Any], process_slug: str, root: Path) -> bool:
133
+ """Return True if the policy applies to this tool call."""
134
+ tools = [t.strip() for t in policy.get("tool", "").split("|")]
135
+ if tool_name not in tools:
136
+ return False
137
+
138
+ condition = policy.get("condition", "")
139
+ if tool_name == "Bash":
140
+ command = tool_args.get("command", "")
141
+ return _command_matches(condition, command)
142
+
143
+ if tool_name in ("Write", "Edit"):
144
+ target = tool_args.get("file_path") or tool_args.get("path") or ""
145
+ return _target_matches(condition, target, process_slug, root)
146
+
147
+ return False
148
+
149
+
150
+ def _log_security_event(
151
+ log_dir: Path,
152
+ tool_name: str,
153
+ tool_args: dict[str, Any],
154
+ result: dict[str, Any],
155
+ ) -> None:
156
+ """Append a JSON line to issue-<N>/logs/security.log."""
157
+ log_dir.mkdir(parents=True, exist_ok=True)
158
+ log_file = log_dir / "security.log"
159
+ entry = {
160
+ "timestamp": datetime.now(timezone.utc).isoformat(),
161
+ "tool": tool_name,
162
+ "level": result["level"],
163
+ "policy": result.get("policy", ""),
164
+ "action": "blocked" if result["confirm_required"] else "allowed",
165
+ "args": {k: v for k, v in tool_args.items() if k in ("command", "file_path", "path")},
166
+ }
167
+ with log_file.open("a", encoding="utf-8") as f:
168
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
169
+
170
+
171
+ def analyze(
172
+ root: Path,
173
+ tool_name: str,
174
+ tool_args: dict[str, Any],
175
+ process_slug: str = "",
176
+ log_dir: str | None = None,
177
+ ) -> dict[str, Any]:
178
+ """Analyze a tool call and return risk assessment."""
179
+ if os.environ.get("LINCOLN_SECURITY_MODE") == "permissive":
180
+ return {
181
+ "level": "low",
182
+ "policy": "",
183
+ "message": "Security analyzer is in permissive mode.",
184
+ "confirm_required": False,
185
+ "log": False,
186
+ }
187
+
188
+ policy_data = load_policy(root)
189
+ policies = policy_data.get("policies", [])
190
+
191
+ for policy in policies:
192
+ if not _evaluate_policy(policy, tool_name, tool_args, process_slug, root):
193
+ continue
194
+
195
+ result = {
196
+ "level": policy.get("level", "unknown"),
197
+ "policy": policy.get("name", ""),
198
+ "message": policy.get("message", ""),
199
+ "confirm_required": bool(policy.get("confirm", False)),
200
+ "log": bool(policy.get("log", False)),
201
+ }
202
+ if log_dir:
203
+ _log_security_event(Path(log_dir), tool_name, tool_args, result)
204
+ return result
205
+
206
+ return {
207
+ "level": "low",
208
+ "policy": "",
209
+ "message": "",
210
+ "confirm_required": False,
211
+ "log": False,
212
+ }
213
+
214
+
215
+ if __name__ == "__main__":
216
+ import argparse
217
+
218
+ parser = argparse.ArgumentParser(description="Analyze tool call risk")
219
+ parser.add_argument("--root", type=Path, default=Path.cwd())
220
+ parser.add_argument("--tool", required=True)
221
+ parser.add_argument("--args", default="{}")
222
+ parser.add_argument("--process-slug", default="")
223
+ parser.add_argument("--log-dir", default=None)
224
+ args = parser.parse_args()
225
+
226
+ tool_args = json.loads(args.args)
227
+ result = analyze(
228
+ args.root,
229
+ args.tool,
230
+ tool_args,
231
+ process_slug=args.process_slug,
232
+ log_dir=args.log_dir,
233
+ )
234
+ print(json.dumps(result, ensure_ascii=False))
@@ -36,7 +36,7 @@ if str(PROJECT_ROOT) not in sys.path:
36
36
 
37
37
  import yaml
38
38
 
39
- from scripts import lincoln_trace
39
+ from scripts import lincoln_condenser, lincoln_trace
40
40
  from scripts import lincoln_render
41
41
  from scripts.lincoln_documents import write_documents_index
42
42
  from scripts.lincoln_index import write_package_index
@@ -84,6 +84,29 @@ def now_iso() -> str:
84
84
  return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
85
85
 
86
86
 
87
+ def _record_stage_event(
88
+ state_file: Path | None,
89
+ category: str,
90
+ event_type: str,
91
+ payload: dict[str, Any] | None = None,
92
+ ) -> None:
93
+ """Write a lifecycle or gate event to the session trace.
94
+
95
+ Failures are logged but never block the stage action.
96
+ """
97
+ try:
98
+ merged_payload = {"event_type": event_type}
99
+ if payload:
100
+ merged_payload.update(payload)
101
+ lincoln_trace.append_event_entry(
102
+ state_path=state_file,
103
+ category=category,
104
+ payload=merged_payload,
105
+ )
106
+ except Exception as exc:
107
+ print(f"Warning: could not record {category}/{event_type} trace event: {exc}")
108
+
109
+
87
110
  def load_yaml(path: Path) -> Any:
88
111
  if not path.exists():
89
112
  raise FileNotFoundError(path)
@@ -483,6 +506,53 @@ def _check_previous_stage_completed(stage_id: str, prev_stage_id: str, state: di
483
506
  return False
484
507
 
485
508
 
509
+
510
+
511
+ def _check_agent_delegation(stage_id: str) -> bool:
512
+ """Validate parallel specialist agent configuration in stage YAML."""
513
+ stage = load_stage_yaml(stage_id)
514
+ agent = stage.get("agent", {})
515
+ specialists = agent.get("parallel_specialists", [])
516
+ if not specialists:
517
+ return True
518
+
519
+ all_valid = True
520
+ for specialist in specialists:
521
+ agent_file = f"{specialist.lstrip('lc-')}.md"
522
+ agent_path = PROJECT_ROOT / ".claude" / "agents" / agent_file
523
+ if not agent_path.exists():
524
+ print(
525
+ f"FAIL: specialist agent '{specialist}' not found at .claude/agents/{agent_file}",
526
+ file=sys.stderr,
527
+ )
528
+ all_valid = False
529
+ else:
530
+ print(f"PASS: specialist agent '{specialist}' found")
531
+
532
+ output_schema = agent.get("output_schema")
533
+ if output_schema:
534
+ schema_path = PROJECT_ROOT / output_schema
535
+ if not schema_path.exists():
536
+ print(f"FAIL: output_schema not found: {output_schema}", file=sys.stderr)
537
+ all_valid = False
538
+ else:
539
+ try:
540
+ json.loads(schema_path.read_text(encoding="utf-8"))
541
+ print(f"PASS: output_schema is valid JSON: {output_schema}")
542
+ except json.JSONDecodeError as exc:
543
+ print(f"FAIL: output_schema is invalid JSON: {exc}", file=sys.stderr)
544
+ all_valid = False
545
+
546
+ merge_strategy = agent.get("merge_strategy", "priority")
547
+ if merge_strategy not in {"priority", "vote", "consensus"}:
548
+ print(f"FAIL: unsupported merge_strategy '{merge_strategy}'", file=sys.stderr)
549
+ all_valid = False
550
+ else:
551
+ print(f"PASS: merge_strategy '{merge_strategy}' supported")
552
+
553
+ return all_valid
554
+
555
+
486
556
  def _run_gate_check(
487
557
  phase: str,
488
558
  check: dict[str, Any],
@@ -564,6 +634,10 @@ def action_validate(
564
634
  if not passed:
565
635
  all_passed = False
566
636
 
637
+ if phase == "entry":
638
+ if not _check_agent_delegation(stage_id):
639
+ all_passed = False
640
+
567
641
  if not all_passed:
568
642
  if _is_legacy_state(state):
569
643
  stage_state["status"] = "validation_failed"
@@ -638,6 +712,13 @@ def action_record_artifacts(
638
712
  print(f"Recorded {len(recorded)} artifacts for stage '{stage_id}'")
639
713
  for art in recorded:
640
714
  print(f" - {art}")
715
+
716
+ _record_stage_event(
717
+ state_file,
718
+ "stage_lifecycle",
719
+ "artifacts_recorded",
720
+ {"stage_id": stage_id, "artifact_count": len(recorded), "artifacts": recorded},
721
+ )
641
722
  return recorded
642
723
 
643
724
 
@@ -677,6 +758,13 @@ def action_approve_gate(
677
758
 
678
759
  save_state(state, state_file)
679
760
  print(f"PASS: gate approved for stage '{stage_id}' by '{approved_by}'")
761
+
762
+ _record_stage_event(
763
+ state_file,
764
+ "gate",
765
+ "gate_approved",
766
+ {"stage_id": stage_id, "approved_by": approved_by},
767
+ )
680
768
  return 0
681
769
 
682
770
 
@@ -799,6 +887,27 @@ def action_transition_next(stage_id: str, state: dict[str, Any], state_file: Pat
799
887
 
800
888
  save_state(state, state_file)
801
889
  print(f"Transitioned from '{stage_id}' to '{next_stage_id}'")
890
+
891
+ _record_stage_event(
892
+ state_file,
893
+ "stage_lifecycle",
894
+ "stage_completed",
895
+ {"stage_id": stage_id, "next_stage_id": next_stage_id},
896
+ )
897
+ if next_stage_id:
898
+ _record_stage_event(
899
+ state_file,
900
+ "stage_lifecycle",
901
+ "stage_started",
902
+ {"stage_id": next_stage_id, "previous_stage_id": stage_id},
903
+ )
904
+ else:
905
+ _record_stage_event(
906
+ state_file,
907
+ "session",
908
+ "workflow_completed",
909
+ {"final_stage_id": stage_id},
910
+ )
802
911
  return next_stage_id
803
912
 
804
913
 
@@ -900,6 +1009,20 @@ def action_handoff_report(stage_id: str, state: dict[str, Any], state_file: Path
900
1009
  else:
901
1010
  lines.append(f"- **Status:** {stage_state.get('status', state.get('current_run', {}).get('status', 'unknown'))}")
902
1011
  lines.append("")
1012
+
1013
+ # Condense large traces as a handoff sub-step (OpenHands-style context pressure management).
1014
+ try:
1015
+ trace_file = lincoln_trace.get_trace_file(state, state_file)
1016
+ condensation = lincoln_condenser.condense(trace_file)
1017
+ if condensation:
1018
+ lines.append("## Trace Condensation")
1019
+ lines.append(f"- **Events summarized:** {condensation['event_count']}")
1020
+ lines.append(f"- **Threshold:** {condensation.get('threshold', 200)}")
1021
+ lines.append(f"- **Top categories:** {', '.join(condensation.get('categories', {}).keys())}")
1022
+ lines.append("")
1023
+ except Exception:
1024
+ # Condensation is advisory; never break handoff report generation.
1025
+ pass
903
1026
  lines.append("## Waiting For")
904
1027
  lines.append(f"- **Waiting for:** {'human' if stage_def.get('human_gate') and not stage_state.get('gate_passed') else 'agent'}")
905
1028
  lines.append("")
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@sushanglewis/lincoln",
3
- "version": "1.6.3",
3
+ "version": "1.8.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@sushanglewis/lincoln",
9
- "version": "1.6.3",
9
+ "version": "1.8.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "ink": "^5.2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sushanglewis/lincoln-recorder",
3
- "version": "1.6.3",
3
+ "version": "1.8.0",
4
4
  "description": "Cross-tool terminal UI for recording Lincoln interviews",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lincoln-install",
3
- "version": "1.6.3",
3
+ "version": "1.8.0",
4
4
  "description": "Terminal TUI installer for the Lincoln AI-Native R&D workflow system",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",
@@ -49,5 +49,10 @@
49
49
  "claude-code",
50
50
  "ai-workflow"
51
51
  ],
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "https://github.com/sushanglewis/Lincoln",
55
+ "directory": "tools/lincoln-installer"
56
+ },
52
57
  "license": "MIT"
53
58
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sushanglewis/lincoln",
3
- "version": "1.6.3",
3
+ "version": "1.8.0",
4
4
  "description": "Global Lincoln agent harness plugin CLI",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -50,5 +50,10 @@
50
50
  "opencode",
51
51
  "plugin"
52
52
  ],
53
+ "repository": {
54
+ "type": "git",
55
+ "url": "https://github.com/sushanglewis/Lincoln",
56
+ "directory": "packages/lincoln"
57
+ },
53
58
  "license": "MIT"
54
59
  }