@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.
- 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/build-product-prototype/prompts/main.md +6 -1
- package/framework/.claude/skills/clarify-requirements/SKILL.md +1 -0
- package/framework/.claude/skills/clarify-requirements/prompts/main.md +50 -10
- package/framework/.claude/skills/draft-product-design/SKILL.md +1 -0
- package/framework/.claude/skills/draft-product-design/prompts/main.md +26 -3
- package/framework/.claude/skills/lc-workflow/validators/validate.py +1 -0
- 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/stages/product-design-docs.yaml +2 -0
- package/framework/.claude/workflows/bug-fix.yaml +1 -0
- package/framework/.claude/workflows/design-spike.yaml +1 -0
- package/framework/.claude/workflows/existing-project-iteration.yaml +1 -0
- package/framework/.claude/workflows/framework-self-improvement.yaml +100 -0
- package/framework/.claude/workflows/interview-to-knowledge.yaml +2 -0
- package/framework/.claude/workflows/oss-first-design.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 +43 -6
- package/framework/README.md +43 -6
- package/framework/RELEASE.md +42 -0
- package/framework/SKILL.md +15 -6
- package/framework/manifest.json +103 -47
- 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_index.py +8 -1
- 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
|
@@ -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
|
-
|
|
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 = "
|
|
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
|
|
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(
|
|
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
|
|