@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
@@ -26,6 +26,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
26
26
  from scripts.lincoln_paths import STATE_FILENAME, is_process_state_path
27
27
 
28
28
  PACKAGE_DATA_FILENAME = "assets/js/package-data.js"
29
+ ARCHIVE_NAV_GROUP = "归档"
29
30
  VERSION_COMMENT_RE = re.compile(r"<!--\s*version:\s*(v\d+\.\d+)\s*-->", re.IGNORECASE)
30
31
  STATUS_COMMENT_RE = re.compile(r"<!--\s*status:\s*(\w+)\s*-->", re.IGNORECASE)
31
32
  LIST_META_KEYS = ("doc-fields", "doc-stories", "doc-rules", "doc-boundaries", "doc-exceptions", "doc-refs")
@@ -183,11 +184,13 @@ def build_package_data(
183
184
  if not entry.get("uid"):
184
185
  meta_warnings.append(f"{rel}: missing <meta name=\"doc-uid\"> or <meta name=\"page-uid\">")
185
186
  group_name = entry.get("group") or entry.get("nav_group") or "Docs"
187
+ archived = group_name == ARCHIVE_NAV_GROUP
186
188
  groups.setdefault(group_name, []).append(
187
189
  {
188
190
  "path": rel,
189
191
  "label": entry.get("nav_label") or entry.get("label") or entry.get("title") or Path(rel).stem,
190
192
  "title": entry.get("title") or Path(rel).stem,
193
+ "archived": archived,
191
194
  "version": entry.get("version"),
192
195
  "status": entry.get("status", ""),
193
196
  "stage": entry.get("stage", ""),
@@ -210,7 +213,11 @@ def build_package_data(
210
213
  for warning in meta_warnings:
211
214
  print(f" - {warning}", file=sys.stderr)
212
215
 
213
- nav = [{"group": g, "items": items} for g, items in groups.items()]
216
+ # Archive groups render last and collapsed in the portal.
217
+ nav = (
218
+ [{"group": g, "items": items} for g, items in groups.items() if g != ARCHIVE_NAV_GROUP]
219
+ + [{"group": g, "items": items} for g, items in groups.items() if g == ARCHIVE_NAV_GROUP]
220
+ )
214
221
 
215
222
  return {
216
223
  "process_slug": process_slug,
@@ -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())
@@ -31,7 +31,7 @@ from scripts.lincoln_paths import ( # noqa: E402
31
31
  process_package_root,
32
32
  )
33
33
 
34
- TRACE_SCHEMA_VERSION = "1.0.0"
34
+ TRACE_SCHEMA_VERSION = "2.0.0"
35
35
 
36
36
  # Tools whose invocations are not traced to avoid recursion or noise.
37
37
  NO_TRACE_TOOLS = {"Read", "Grep", "Glob"}
@@ -46,6 +46,26 @@ TASK_TOOLS = frozenset({
46
46
  "TaskStop",
47
47
  })
48
48
 
49
+ # All valid trace categories. Tool categories are auto-derived; lifecycle categories
50
+ # are written explicitly by stage_loader and hooks.
51
+ VALID_CATEGORIES = frozenset({
52
+ "tool",
53
+ "skill",
54
+ "agent",
55
+ "read",
56
+ "write",
57
+ "edit",
58
+ "bash",
59
+ "task",
60
+ "mcp",
61
+ "handoff",
62
+ "other",
63
+ "stage_lifecycle",
64
+ "gate",
65
+ "session",
66
+ "condensation",
67
+ })
68
+
49
69
  # Maximum length stored for a Bash command summary in the trace entry.
50
70
  COMMAND_SUMMARY_MAX_LEN = 500
51
71
 
@@ -202,6 +222,11 @@ def _resolve_trace_file(state: dict[str, Any] | None, state_path: Path | None) -
202
222
  return trace_dir / "lc-trace.jsonl"
203
223
 
204
224
 
225
+ def get_trace_file(state: dict[str, Any] | None = None, state_path: Path | None = None) -> Path:
226
+ """Public accessor for the resolved trace file path."""
227
+ return _resolve_trace_file(state, state_path)
228
+
229
+
205
230
  def _get_run_id(state: dict[str, Any] | None, run_id: str | None) -> str:
206
231
  if run_id:
207
232
  return run_id
@@ -250,12 +275,26 @@ def _build_trace_target(tool: str, args_summary: dict[str, Any]) -> str:
250
275
  return str(args_summary.get("path", args_summary.get("name", "")))
251
276
 
252
277
 
278
+ def _get_node_id(state: dict[str, Any] | None, node_id: str | None) -> str | None:
279
+ if node_id:
280
+ return node_id
281
+ if state:
282
+ current_run = state.get("current_run", {})
283
+ # Find the in_progress node for the current stage.
284
+ current_stage = current_run.get("current_stage", "")
285
+ for node in state.get("nodes", []):
286
+ if node.get("stage_id") == current_stage and node.get("status") == "in_progress":
287
+ return str(node.get("node_id", "")) or None
288
+ return None
289
+
290
+
253
291
  def _build_trace_entry(
254
292
  tool: str,
255
293
  args_summary: dict[str, Any],
256
294
  exit_code: int,
257
295
  run_id: str,
258
296
  stage: str,
297
+ node_id: str | None = None,
259
298
  ) -> dict[str, Any]:
260
299
  return {
261
300
  "schema_version": TRACE_SCHEMA_VERSION,
@@ -263,6 +302,7 @@ def _build_trace_entry(
263
302
  "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
264
303
  "run_id": run_id,
265
304
  "stage": stage,
305
+ "node_id": node_id,
266
306
  "tool": tool,
267
307
  "category": categorize_tool(tool),
268
308
  "target": _build_trace_target(tool, args_summary),
@@ -290,8 +330,9 @@ def append_trace_entry(
290
330
  exit_code: int,
291
331
  stage: str | None = None,
292
332
  run_id: str | None = None,
333
+ node_id: str | None = None,
293
334
  ) -> Path | None:
294
- """Append a single trace entry to the appropriate JSONL file.
335
+ """Append a single trace entry for a tool invocation.
295
336
 
296
337
  The write is atomic with respect to other processes on the same host: the
297
338
  file is locked while appending, so concurrent parent and child agents do
@@ -303,9 +344,84 @@ def append_trace_entry(
303
344
  state = _load_state(state_path)
304
345
  resolved_run_id = _get_run_id(state, run_id)
305
346
  resolved_stage = _get_stage(state, stage)
347
+ resolved_node_id = _get_node_id(state, node_id)
306
348
  trace_file = _resolve_trace_file(state, state_path)
307
349
  args_summary = redact_args(tool, _safe_json_loads(args))
308
- entry = _build_trace_entry(tool, args_summary, exit_code, resolved_run_id, resolved_stage)
350
+ entry = _build_trace_entry(
351
+ tool, args_summary, exit_code, resolved_run_id, resolved_stage, resolved_node_id
352
+ )
353
+ _append_trace_line(trace_file, entry)
354
+ return trace_file
355
+
356
+
357
+ def append_event_entry(
358
+ state_path: Path | None,
359
+ category: str,
360
+ payload: dict[str, Any],
361
+ stage: str | None = None,
362
+ run_id: str | None = None,
363
+ node_id: str | None = None,
364
+ ) -> Path | None:
365
+ """Append a non-tool lifecycle event to the trace.
366
+
367
+ Categories must be one of VALID_CATEGORIES. The payload is stored under
368
+ ``args_summary`` for backward compatibility with existing readers.
369
+ """
370
+ if category not in VALID_CATEGORIES:
371
+ raise ValueError(f"Invalid trace category: {category!r}")
372
+
373
+ state = _load_state(state_path)
374
+ resolved_run_id = _get_run_id(state, run_id)
375
+ resolved_stage = _get_stage(state, stage)
376
+ resolved_node_id = _get_node_id(state, node_id)
377
+ trace_file = _resolve_trace_file(state, state_path)
378
+
379
+ entry: dict[str, Any] = {
380
+ "schema_version": TRACE_SCHEMA_VERSION,
381
+ "sequence_id": generate_sequence_id(),
382
+ "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
383
+ "run_id": resolved_run_id,
384
+ "stage": resolved_stage,
385
+ "node_id": resolved_node_id,
386
+ "tool": "",
387
+ "category": category,
388
+ "target": str(payload.get("event_type", payload.get("type", "")))[:200],
389
+ "exit_code": int(payload.get("exit_code", 0)),
390
+ "args_summary": payload,
391
+ }
392
+ _append_trace_line(trace_file, entry)
393
+ return trace_file
394
+
395
+
396
+ def append_event_to_file(
397
+ trace_file: Path,
398
+ category: str,
399
+ payload: dict[str, Any],
400
+ stage: str | None = None,
401
+ run_id: str | None = None,
402
+ node_id: str | None = None,
403
+ ) -> Path:
404
+ """Append a non-tool lifecycle event to an explicit trace file path.
405
+
406
+ This variant is useful when the caller has already resolved the trace file
407
+ (e.g. the condenser) and wants to avoid re-resolving it from state.
408
+ """
409
+ if category not in VALID_CATEGORIES:
410
+ raise ValueError(f"Invalid trace category: {category!r}")
411
+
412
+ entry: dict[str, Any] = {
413
+ "schema_version": TRACE_SCHEMA_VERSION,
414
+ "sequence_id": generate_sequence_id(),
415
+ "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
416
+ "run_id": run_id or "unknown",
417
+ "stage": stage or "unknown",
418
+ "node_id": node_id or "unknown",
419
+ "tool": "",
420
+ "category": category,
421
+ "target": str(payload.get("event_type", payload.get("type", "")))[:200],
422
+ "exit_code": int(payload.get("exit_code", 0)),
423
+ "args_summary": payload,
424
+ }
309
425
  _append_trace_line(trace_file, entry)
310
426
  return trace_file
311
427