@sushanglewis/lincoln 1.5.4 → 1.5.5
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/designer.md +43 -8
- package/framework/.claude/agents/external/NOTICES.md +1 -1
- package/framework/.claude/agents/external/everything-claude-code/agents/ecc-tdd-guide.md +108 -0
- package/framework/.claude/agents/external/everything-claude-code.manifest.yaml +1 -1
- package/framework/.claude/agents/external/oh-my-claudecode/agents/omc-architect.md +134 -0
- package/framework/.claude/agents/external/oh-my-claudecode/agents/omc-code-reviewer.md +248 -0
- package/framework/.claude/agents/external/oh-my-claudecode/agents/omc-designer.md +122 -0
- package/framework/.claude/agents/external/oh-my-claudecode/agents/omc-qa-tester.md +106 -0
- package/framework/.claude/agents/external/wshobson-agents/agents/wshobson-frontend-developer.md +173 -0
- package/framework/.claude/agents/frontend-engineer.md +3 -3
- package/framework/.claude/agents/pm.md +67 -14
- package/framework/.claude/agents/qa.md +1 -1
- package/framework/.claude/hooks/pre-tool-use.sh +3 -3
- package/framework/.claude/skills/build-product-prototype/SKILL.md +1 -1
- package/framework/.claude/skills/dependencies.yaml +6 -0
- package/framework/.claude/skills/draft-product-design/SKILL.md +2 -0
- package/framework/.claude/skills/draft-product-design/prompts/main.md +6 -5
- package/framework/.claude/skills/lc-workflow/validators/validate.py +2 -0
- package/framework/.claude/stages/clarify.yaml +1 -1
- package/framework/.claude/stages/product-design-docs.yaml +4 -2
- package/framework/.claude/stages/product-prototype.yaml +4 -3
- package/framework/.claude/workflows/README.md +25 -3
- package/framework/.claude/workflows/interview-to-knowledge.yaml +3 -2
- 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/manifest.json +56 -36
- package/framework/scripts/check-prompt-drift.py +526 -0
- package/framework/scripts/static-check.sh +39 -0
- package/framework/scripts/sync-external-agents.sh +2 -0
- package/framework/tools/lincoln/package-lock.json +2 -2
- package/framework/tools/lincoln/package.json +1 -1
- package/framework/tools/lincoln-installer/package-lock.json +2 -2
- package/framework/tools/lincoln-installer/package.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Check prompt drift across Lincoln's agent/stage/workflow/skill layers.
|
|
3
|
+
|
|
4
|
+
Rules:
|
|
5
|
+
R1: agent extends targets must exist.
|
|
6
|
+
R2: workflow step action must be in corresponding stage skills.required.
|
|
7
|
+
R3: workflow step artifacts must match stage artifacts.
|
|
8
|
+
R4: primary skill outputs must match stage artifacts.
|
|
9
|
+
R5: agent artifact paths must be a subset of stage artifacts.
|
|
10
|
+
R6: stage skill references must be declared in dependencies or local skills.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import yaml
|
|
22
|
+
|
|
23
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
24
|
+
AGENTS_DIR = ROOT / ".claude" / "agents"
|
|
25
|
+
STAGES_DIR = ROOT / ".claude" / "stages"
|
|
26
|
+
WORKFLOWS_DIR = ROOT / ".claude" / "workflows"
|
|
27
|
+
SKILLS_DIR = ROOT / ".claude" / "skills"
|
|
28
|
+
DEP_FILE = ROOT / ".claude" / "skills" / "dependencies.yaml"
|
|
29
|
+
|
|
30
|
+
PM_STAGES = {"clarify", "product-design-docs", "product-prototype"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse_frontmatter(path: Path) -> dict[str, Any]:
|
|
34
|
+
text = path.read_text(encoding="utf-8")
|
|
35
|
+
if not text.startswith("---"):
|
|
36
|
+
return {}
|
|
37
|
+
end = text.find("---", 3)
|
|
38
|
+
if end == -1:
|
|
39
|
+
return {}
|
|
40
|
+
try:
|
|
41
|
+
return yaml.safe_load(text[3:end]) or {}
|
|
42
|
+
except yaml.YAMLError:
|
|
43
|
+
return {}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_yaml(path: Path) -> Any:
|
|
47
|
+
return yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def normalize_path(path: str) -> str:
|
|
51
|
+
"""Strip trailing slashes and collapse placeholders for comparison."""
|
|
52
|
+
return path.rstrip("/")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def glob_to_regex(pattern: str) -> re.Pattern[str]:
|
|
56
|
+
"""Convert a simple glob pattern with * and ** to a regex."""
|
|
57
|
+
parts = []
|
|
58
|
+
i = 0
|
|
59
|
+
while i < len(pattern):
|
|
60
|
+
if pattern[i : i + 2] == "**":
|
|
61
|
+
parts.append(".*")
|
|
62
|
+
i += 2
|
|
63
|
+
elif pattern[i] == "*":
|
|
64
|
+
parts.append("[^/]*")
|
|
65
|
+
i += 1
|
|
66
|
+
else:
|
|
67
|
+
parts.append(re.escape(pattern[i]))
|
|
68
|
+
i += 1
|
|
69
|
+
return re.compile("^" + "".join(parts) + "$")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def paths_match(a: str, b: str) -> bool:
|
|
73
|
+
"""Compare two artifact paths, treating globs specially."""
|
|
74
|
+
a = normalize_path(a)
|
|
75
|
+
b = normalize_path(b)
|
|
76
|
+
if a == b:
|
|
77
|
+
return True
|
|
78
|
+
# If one is a glob, see if the other matches.
|
|
79
|
+
if "*" in a:
|
|
80
|
+
return bool(glob_to_regex(a).match(b))
|
|
81
|
+
if "*" in b:
|
|
82
|
+
return bool(glob_to_regex(b).match(a))
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def path_in_set(path: str, path_set: set[str]) -> bool:
|
|
87
|
+
for candidate in path_set:
|
|
88
|
+
if paths_match(path, candidate):
|
|
89
|
+
return True
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def collect_agents() -> dict[str, dict[str, Any]]:
|
|
94
|
+
agents: dict[str, dict[str, Any]] = {}
|
|
95
|
+
for path in sorted(AGENTS_DIR.glob("*.md")):
|
|
96
|
+
front = parse_frontmatter(path)
|
|
97
|
+
agents[path.name] = {
|
|
98
|
+
"path": path,
|
|
99
|
+
"front": front,
|
|
100
|
+
"body": path.read_text(encoding="utf-8"),
|
|
101
|
+
}
|
|
102
|
+
return agents
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def collect_stages() -> dict[str, dict[str, Any]]:
|
|
106
|
+
stages: dict[str, dict[str, Any]] = {}
|
|
107
|
+
for path in sorted(STAGES_DIR.glob("*.yaml")):
|
|
108
|
+
if path.name == "stage-manifest.yaml":
|
|
109
|
+
continue
|
|
110
|
+
data = load_yaml(path)
|
|
111
|
+
if isinstance(data, dict):
|
|
112
|
+
stages[data.get("id", path.stem)] = {"path": path, "data": data}
|
|
113
|
+
return stages
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def collect_workflows() -> dict[str, dict[str, Any]]:
|
|
117
|
+
workflows: dict[str, dict[str, Any]] = {}
|
|
118
|
+
for path in sorted(WORKFLOWS_DIR.glob("*.yaml")):
|
|
119
|
+
data = load_yaml(path)
|
|
120
|
+
if isinstance(data, dict) and "workflow" in data:
|
|
121
|
+
workflows[data["workflow"].get("name", path.stem)] = {"path": path, "data": data["workflow"]}
|
|
122
|
+
return workflows
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def collect_skills() -> dict[str, dict[str, Any]]:
|
|
126
|
+
skills: dict[str, dict[str, Any]] = {}
|
|
127
|
+
for path in sorted(SKILLS_DIR.glob("*/SKILL.md")):
|
|
128
|
+
front = parse_frontmatter(path)
|
|
129
|
+
skills[path.parent.name] = {"path": path, "front": front}
|
|
130
|
+
return skills
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def collect_dependencies() -> set[str]:
|
|
134
|
+
declared: set[str] = set()
|
|
135
|
+
if not DEP_FILE.exists():
|
|
136
|
+
return declared
|
|
137
|
+
data = load_yaml(DEP_FILE)
|
|
138
|
+
if not isinstance(data, dict):
|
|
139
|
+
return declared
|
|
140
|
+
for namespace, info in (data.get("skills") or {}).items():
|
|
141
|
+
if not isinstance(info, dict):
|
|
142
|
+
continue
|
|
143
|
+
# Local inline skills are implicitly declared.
|
|
144
|
+
if info.get("source") == "inline" or info.get("type") == "inline":
|
|
145
|
+
declared.add(namespace)
|
|
146
|
+
continue
|
|
147
|
+
# Register the namespace itself so any namespaced ref under it is acceptable.
|
|
148
|
+
declared.add(namespace)
|
|
149
|
+
for skill in info.get("required_skills", []) or []:
|
|
150
|
+
declared.add(normalize_skill_ref(namespace, skill))
|
|
151
|
+
for skill in info.get("recommended_skills", []) or []:
|
|
152
|
+
declared.add(normalize_skill_ref(namespace, skill))
|
|
153
|
+
return declared
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def normalize_skill_ref(namespace: str, skill: str) -> str:
|
|
157
|
+
"""Return a canonical namespaced skill reference.
|
|
158
|
+
|
|
159
|
+
dependencies.yaml mixes formats: some entries are already namespaced
|
|
160
|
+
("superpowers:brainstorming", "oh-my-claudecode:plan") while others use
|
|
161
|
+
a prefixed bare name ("gsd-import"). Normalize to "namespace:name".
|
|
162
|
+
"""
|
|
163
|
+
skill = skill.strip()
|
|
164
|
+
if skill.startswith(f"{namespace}:"):
|
|
165
|
+
return skill
|
|
166
|
+
if skill.startswith(f"{namespace}-"):
|
|
167
|
+
return f"{namespace}:{skill[len(namespace) + 1:]}"
|
|
168
|
+
return f"{namespace}:{skill}"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def collect_local_skill_names() -> set[str]:
|
|
172
|
+
return {p.parent.name for p in SKILLS_DIR.glob("*/SKILL.md")}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def artifact_paths(stage: dict[str, Any]) -> tuple[set[str], set[str]]:
|
|
176
|
+
required = set()
|
|
177
|
+
optional = set()
|
|
178
|
+
artifacts = stage.get("artifacts", {})
|
|
179
|
+
for p in artifacts.get("required", []):
|
|
180
|
+
required.add(normalize_path(str(p)))
|
|
181
|
+
for p in artifacts.get("optional", []):
|
|
182
|
+
optional.add(normalize_path(str(p)))
|
|
183
|
+
return required, optional
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def check_r1_extends_resolve(agents: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
|
|
187
|
+
findings = []
|
|
188
|
+
for name, info in agents.items():
|
|
189
|
+
for extends_ref in info["front"].get("extends", []) or []:
|
|
190
|
+
resolved = ROOT / ".claude" / extends_ref
|
|
191
|
+
if not resolved.exists():
|
|
192
|
+
findings.append({
|
|
193
|
+
"rule": "R1",
|
|
194
|
+
"severity": "error",
|
|
195
|
+
"stage_focus": False,
|
|
196
|
+
"file": f".claude/agents/{name}",
|
|
197
|
+
"message": f"extends '{extends_ref}' does not resolve to an existing file",
|
|
198
|
+
})
|
|
199
|
+
return findings
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def check_r2_action_in_stage_skills(
|
|
203
|
+
workflows: dict[str, dict[str, Any]],
|
|
204
|
+
stages: dict[str, dict[str, Any]],
|
|
205
|
+
) -> list[dict[str, Any]]:
|
|
206
|
+
findings = []
|
|
207
|
+
for wf_name, wf_info in workflows.items():
|
|
208
|
+
for step in wf_info["data"].get("steps", []) or []:
|
|
209
|
+
stage_id = step.get("id")
|
|
210
|
+
action = step.get("action")
|
|
211
|
+
if not stage_id or not action:
|
|
212
|
+
continue
|
|
213
|
+
stage = stages.get(stage_id)
|
|
214
|
+
if not stage:
|
|
215
|
+
continue
|
|
216
|
+
stage_name = stage["data"].get("id", stage_id)
|
|
217
|
+
required_skills = stage["data"].get("skills", {}).get("required", []) or []
|
|
218
|
+
# Workflow actions map to either local skills (<action>) or namespaced skills.
|
|
219
|
+
possible_ids = {action, f"lincoln:{action}"}
|
|
220
|
+
if not any(skill in possible_ids for skill in required_skills):
|
|
221
|
+
findings.append({
|
|
222
|
+
"rule": "R2",
|
|
223
|
+
"severity": "error",
|
|
224
|
+
"stage_focus": stage_name in PM_STAGES,
|
|
225
|
+
"file": f".claude/workflows/{wf_name}.yaml",
|
|
226
|
+
"message": (
|
|
227
|
+
f"step '{stage_id}' action '{action}' is not listed in "
|
|
228
|
+
f"stage '{stage_id}' skills.required: {required_skills}"
|
|
229
|
+
),
|
|
230
|
+
})
|
|
231
|
+
return findings
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
PM_CANONICAL_WORKFLOW = "interview-to-knowledge"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def check_r3_workflow_artifacts_match_stage(
|
|
238
|
+
workflows: dict[str, dict[str, Any]],
|
|
239
|
+
stages: dict[str, dict[str, Any]],
|
|
240
|
+
) -> list[dict[str, Any]]:
|
|
241
|
+
findings = []
|
|
242
|
+
for wf_name, wf_info in workflows.items():
|
|
243
|
+
# For PM stages, only the canonical workflow is held to strict artifact equality.
|
|
244
|
+
# Other workflows may intentionally use a subset of PM stage artifacts.
|
|
245
|
+
is_pm_canonical = wf_name == PM_CANONICAL_WORKFLOW
|
|
246
|
+
for step in wf_info["data"].get("steps", []) or []:
|
|
247
|
+
stage_id = step.get("id")
|
|
248
|
+
if not stage_id:
|
|
249
|
+
continue
|
|
250
|
+
stage = stages.get(stage_id)
|
|
251
|
+
if not stage:
|
|
252
|
+
continue
|
|
253
|
+
stage_name = stage["data"].get("id", stage_id)
|
|
254
|
+
is_pm_stage = stage_name in PM_STAGES
|
|
255
|
+
stage_required, stage_optional = artifact_paths(stage["data"])
|
|
256
|
+
|
|
257
|
+
wf_required = {normalize_path(p) for p in step.get("artifacts", []) or []}
|
|
258
|
+
wf_optional = {normalize_path(p) for p in step.get("optional_artifacts", []) or []}
|
|
259
|
+
|
|
260
|
+
severity = "error" if (is_pm_stage and is_pm_canonical) else "warning"
|
|
261
|
+
|
|
262
|
+
# Compare required sets modulo glob matching.
|
|
263
|
+
for p in wf_required:
|
|
264
|
+
if not path_in_set(p, stage_required):
|
|
265
|
+
findings.append({
|
|
266
|
+
"rule": "R3",
|
|
267
|
+
"severity": severity,
|
|
268
|
+
"stage_focus": is_pm_stage,
|
|
269
|
+
"file": f".claude/workflows/{wf_name}.yaml",
|
|
270
|
+
"message": f"step '{stage_id}' required artifact '{p}' missing from stage '{stage_id}' required artifacts",
|
|
271
|
+
})
|
|
272
|
+
for p in stage_required:
|
|
273
|
+
if not path_in_set(p, wf_required):
|
|
274
|
+
findings.append({
|
|
275
|
+
"rule": "R3",
|
|
276
|
+
"severity": severity,
|
|
277
|
+
"stage_focus": is_pm_stage,
|
|
278
|
+
"file": f".claude/stages/{stage_id}.yaml",
|
|
279
|
+
"message": f"stage '{stage_id}' required artifact '{p}' missing from workflow '{wf_name}' step artifacts",
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
for p in wf_optional:
|
|
283
|
+
if not (path_in_set(p, stage_optional) or path_in_set(p, stage_required)):
|
|
284
|
+
findings.append({
|
|
285
|
+
"rule": "R3",
|
|
286
|
+
"severity": "warning",
|
|
287
|
+
"stage_focus": is_pm_stage,
|
|
288
|
+
"file": f".claude/workflows/{wf_name}.yaml",
|
|
289
|
+
"message": f"step '{stage_id}' optional artifact '{p}' not declared in stage '{stage_id}' artifacts",
|
|
290
|
+
})
|
|
291
|
+
for p in stage_optional:
|
|
292
|
+
if not path_in_set(p, wf_optional):
|
|
293
|
+
findings.append({
|
|
294
|
+
"rule": "R3",
|
|
295
|
+
"severity": "warning",
|
|
296
|
+
"stage_focus": is_pm_stage,
|
|
297
|
+
"file": f".claude/stages/{stage_id}.yaml",
|
|
298
|
+
"message": f"stage '{stage_id}' optional artifact '{p}' missing from workflow '{wf_name}' optional_artifacts",
|
|
299
|
+
})
|
|
300
|
+
return findings
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def check_r4_skill_outputs_match_stage(
|
|
304
|
+
workflows: dict[str, dict[str, Any]],
|
|
305
|
+
stages: dict[str, dict[str, Any]],
|
|
306
|
+
skills: dict[str, dict[str, Any]],
|
|
307
|
+
) -> list[dict[str, Any]]:
|
|
308
|
+
findings = []
|
|
309
|
+
for wf_name, wf_info in workflows.items():
|
|
310
|
+
for step in wf_info["data"].get("steps", []) or []:
|
|
311
|
+
stage_id = step.get("id")
|
|
312
|
+
action = step.get("action")
|
|
313
|
+
if not stage_id or not action:
|
|
314
|
+
continue
|
|
315
|
+
stage = stages.get(stage_id)
|
|
316
|
+
if not stage:
|
|
317
|
+
continue
|
|
318
|
+
stage_name = stage["data"].get("id", stage_id)
|
|
319
|
+
stage_required, stage_optional = artifact_paths(stage["data"])
|
|
320
|
+
|
|
321
|
+
# Find the primary skill by action name.
|
|
322
|
+
primary_skill = skills.get(action)
|
|
323
|
+
if primary_skill is None:
|
|
324
|
+
# Also try namespaced form.
|
|
325
|
+
for key, val in skills.items():
|
|
326
|
+
if val["front"].get("name") == action or val["front"].get("name") == f"lincoln:{action}":
|
|
327
|
+
primary_skill = val
|
|
328
|
+
break
|
|
329
|
+
if primary_skill is None:
|
|
330
|
+
continue
|
|
331
|
+
|
|
332
|
+
outputs = {normalize_path(p) for p in primary_skill["front"].get("outputs", []) or []}
|
|
333
|
+
all_stage_artifacts = stage_required | stage_optional
|
|
334
|
+
for p in outputs:
|
|
335
|
+
if not path_in_set(p, all_stage_artifacts):
|
|
336
|
+
findings.append({
|
|
337
|
+
"rule": "R4",
|
|
338
|
+
"severity": "warning",
|
|
339
|
+
"stage_focus": stage_name in PM_STAGES,
|
|
340
|
+
"file": str(primary_skill["path"].relative_to(ROOT)),
|
|
341
|
+
"message": f"skill output '{p}' not declared in stage '{stage_id}' artifacts",
|
|
342
|
+
})
|
|
343
|
+
for p in stage_required:
|
|
344
|
+
if not path_in_set(p, outputs):
|
|
345
|
+
findings.append({
|
|
346
|
+
"rule": "R4",
|
|
347
|
+
"severity": "error",
|
|
348
|
+
"stage_focus": stage_name in PM_STAGES,
|
|
349
|
+
"file": f".claude/stages/{stage_id}.yaml",
|
|
350
|
+
"message": f"stage '{stage_id}' required artifact '{p}' missing from skill '{action}' outputs",
|
|
351
|
+
})
|
|
352
|
+
return findings
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def extract_output_sections(body: str) -> list[str]:
|
|
356
|
+
"""Return text blocks under output/artifact sections in agent markdown."""
|
|
357
|
+
sections = []
|
|
358
|
+
# Match headings like ## 产物规范, ## Outputs, ## 产物, ### `clarify` 阶段
|
|
359
|
+
heading_re = re.compile(r"^(#{2,4}\s+(?:产物规范|Outputs|产物|Output).*)$", re.MULTILINE | re.IGNORECASE)
|
|
360
|
+
for match in heading_re.finditer(body):
|
|
361
|
+
start = match.end()
|
|
362
|
+
# Find the next heading of same or higher level
|
|
363
|
+
next_heading = re.search(r"^#{1,4}\s+", body[start:], re.MULTILINE)
|
|
364
|
+
end = start + next_heading.start() if next_heading else len(body)
|
|
365
|
+
sections.append(body[start:end])
|
|
366
|
+
return sections
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def extract_artifact_paths(body: str) -> set[str]:
|
|
370
|
+
"""Extract likely artifact output paths from agent markdown body.
|
|
371
|
+
|
|
372
|
+
Only scans sections that describe outputs/artifacts to avoid flagging
|
|
373
|
+
input references (e.g., handoff files read by designer).
|
|
374
|
+
"""
|
|
375
|
+
paths: set[str] = set()
|
|
376
|
+
sections = extract_output_sections(body)
|
|
377
|
+
if not sections:
|
|
378
|
+
sections = [body]
|
|
379
|
+
for section in sections:
|
|
380
|
+
for match in re.finditer(r"`(\{process_slug\}/[^`]+)`", section):
|
|
381
|
+
candidate = match.group(1).strip("`")
|
|
382
|
+
if "/" in candidate:
|
|
383
|
+
paths.add(normalize_path(candidate))
|
|
384
|
+
for match in re.finditer(r"(?:^|\s)(\{process_slug\}/[\w\-./{}*]+)", section):
|
|
385
|
+
candidate = match.group(1).strip()
|
|
386
|
+
if "/" in candidate and not candidate.endswith(":"):
|
|
387
|
+
paths.add(normalize_path(candidate))
|
|
388
|
+
return paths
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def check_r5_agent_paths_in_stage_artifacts(
|
|
392
|
+
agents: dict[str, dict[str, Any]],
|
|
393
|
+
stages: dict[str, dict[str, Any]],
|
|
394
|
+
) -> list[dict[str, Any]]:
|
|
395
|
+
findings = []
|
|
396
|
+
for agent_name, info in agents.items():
|
|
397
|
+
front = info["front"]
|
|
398
|
+
agent_name_attr = front.get("name", agent_name)
|
|
399
|
+
paths = extract_artifact_paths(info["body"])
|
|
400
|
+
if not paths:
|
|
401
|
+
continue
|
|
402
|
+
|
|
403
|
+
# Find all stages where this agent is primary and collect their artifacts.
|
|
404
|
+
agent_stage_ids = []
|
|
405
|
+
all_stage_artifacts: set[str] = set()
|
|
406
|
+
for stage_id, stage in stages.items():
|
|
407
|
+
primary = stage["data"].get("agent", {}).get("primary", "")
|
|
408
|
+
if primary != agent_name_attr:
|
|
409
|
+
continue
|
|
410
|
+
agent_stage_ids.append(stage_id)
|
|
411
|
+
required, optional = artifact_paths(stage["data"])
|
|
412
|
+
all_stage_artifacts |= required | optional
|
|
413
|
+
|
|
414
|
+
if not agent_stage_ids:
|
|
415
|
+
continue
|
|
416
|
+
|
|
417
|
+
for p in paths:
|
|
418
|
+
if not path_in_set(p, all_stage_artifacts):
|
|
419
|
+
findings.append({
|
|
420
|
+
"rule": "R5",
|
|
421
|
+
"severity": "error",
|
|
422
|
+
"stage_focus": any(sid in PM_STAGES for sid in agent_stage_ids),
|
|
423
|
+
"file": f".claude/agents/{agent_name}",
|
|
424
|
+
"message": f"agent '{agent_name_attr}' references artifact '{p}' not declared in any of its primary stages ({', '.join(agent_stage_ids)}) artifacts",
|
|
425
|
+
})
|
|
426
|
+
return findings
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def check_r6_skill_dependencies_declared(
|
|
430
|
+
stages: dict[str, dict[str, Any]],
|
|
431
|
+
declared: set[str],
|
|
432
|
+
local_skills: set[str],
|
|
433
|
+
) -> list[dict[str, Any]]:
|
|
434
|
+
findings = []
|
|
435
|
+
for stage_id, stage in stages.items():
|
|
436
|
+
skills_block = stage["data"].get("skills", {})
|
|
437
|
+
all_refs = list(skills_block.get("required", []) or []) + list(skills_block.get("optional", []) or [])
|
|
438
|
+
for ref in all_refs:
|
|
439
|
+
if ":" not in ref:
|
|
440
|
+
# Bare local skill name.
|
|
441
|
+
if ref not in local_skills:
|
|
442
|
+
findings.append({
|
|
443
|
+
"rule": "R6",
|
|
444
|
+
"severity": "error",
|
|
445
|
+
"stage_focus": stage_id in PM_STAGES,
|
|
446
|
+
"file": f".claude/stages/{stage_id}.yaml",
|
|
447
|
+
"message": f"skill '{ref}' not declared in dependencies.yaml and not a local skill",
|
|
448
|
+
})
|
|
449
|
+
continue
|
|
450
|
+
namespace, skill_name = ref.split(":", 1)
|
|
451
|
+
# Local inline namespace like lincoln:*.
|
|
452
|
+
if namespace in local_skills or namespace == "lincoln":
|
|
453
|
+
# lincoln:* names are mapped to local skill directory names.
|
|
454
|
+
if namespace != "lincoln" and namespace in local_skills:
|
|
455
|
+
continue
|
|
456
|
+
if skill_name in local_skills:
|
|
457
|
+
continue
|
|
458
|
+
# Some lincoln skills use hyphenated names in dirs (e.g., lc-stage).
|
|
459
|
+
if skill_name.replace("-", "_") in {s.replace("-", "_") for s in local_skills}:
|
|
460
|
+
continue
|
|
461
|
+
if ref in declared:
|
|
462
|
+
continue
|
|
463
|
+
# Also accept namespace-only declaration for gsd/superpowers.
|
|
464
|
+
if namespace in declared and f"{namespace}:{skill_name}" not in declared:
|
|
465
|
+
continue
|
|
466
|
+
findings.append({
|
|
467
|
+
"rule": "R6",
|
|
468
|
+
"severity": "error",
|
|
469
|
+
"stage_focus": stage_id in PM_STAGES,
|
|
470
|
+
"file": f".claude/stages/{stage_id}.yaml",
|
|
471
|
+
"message": f"skill '{ref}' not declared in dependencies.yaml or local skills",
|
|
472
|
+
})
|
|
473
|
+
return findings
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def main() -> int:
|
|
477
|
+
parser = argparse.ArgumentParser(description="Check Lincoln prompt drift")
|
|
478
|
+
parser.add_argument("--strict", action="store_true", help="Fail if any PM-stage drift is found")
|
|
479
|
+
parser.add_argument("--focus", choices=["all", "pm"], default="all", help="Focus on PM stages only")
|
|
480
|
+
args = parser.parse_args()
|
|
481
|
+
|
|
482
|
+
agents = collect_agents()
|
|
483
|
+
stages = collect_stages()
|
|
484
|
+
workflows = collect_workflows()
|
|
485
|
+
skills = collect_skills()
|
|
486
|
+
declared = collect_dependencies()
|
|
487
|
+
local_skills = collect_local_skill_names()
|
|
488
|
+
|
|
489
|
+
findings: list[dict[str, Any]] = []
|
|
490
|
+
findings.extend(check_r1_extends_resolve(agents))
|
|
491
|
+
findings.extend(check_r2_action_in_stage_skills(workflows, stages))
|
|
492
|
+
findings.extend(check_r3_workflow_artifacts_match_stage(workflows, stages))
|
|
493
|
+
findings.extend(check_r4_skill_outputs_match_stage(workflows, stages, skills))
|
|
494
|
+
findings.extend(check_r5_agent_paths_in_stage_artifacts(agents, stages))
|
|
495
|
+
findings.extend(check_r6_skill_dependencies_declared(stages, declared, local_skills))
|
|
496
|
+
|
|
497
|
+
# Deduplicate by (rule, file, message).
|
|
498
|
+
seen = set()
|
|
499
|
+
unique_findings = []
|
|
500
|
+
for f in findings:
|
|
501
|
+
key = (f["rule"], f["file"], f["message"])
|
|
502
|
+
if key not in seen:
|
|
503
|
+
seen.add(key)
|
|
504
|
+
unique_findings.append(f)
|
|
505
|
+
findings = unique_findings
|
|
506
|
+
|
|
507
|
+
if args.focus == "pm":
|
|
508
|
+
findings = [f for f in findings if f.get("stage_focus")]
|
|
509
|
+
|
|
510
|
+
errors = [f for f in findings if f["severity"] == "error"]
|
|
511
|
+
warnings = [f for f in findings if f["severity"] == "warning"]
|
|
512
|
+
|
|
513
|
+
for f in findings:
|
|
514
|
+
prefix = "ERROR" if f["severity"] == "error" else "WARN"
|
|
515
|
+
focus = " [PM]" if f.get("stage_focus") else ""
|
|
516
|
+
print(f"{prefix}{focus} {f['rule']} {f['file']}: {f['message']}")
|
|
517
|
+
|
|
518
|
+
print(f"\n{len(errors)} error(s), {len(warnings)} warning(s)")
|
|
519
|
+
|
|
520
|
+
if args.strict:
|
|
521
|
+
return 1 if errors else 0
|
|
522
|
+
return 0
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
if __name__ == "__main__":
|
|
526
|
+
sys.exit(main())
|
|
@@ -163,6 +163,42 @@ if errors:
|
|
|
163
163
|
print("All agent frontmatters valid.")
|
|
164
164
|
PY
|
|
165
165
|
|
|
166
|
+
echo "==> Validate imported external agents are in sync with manifests"
|
|
167
|
+
"$PYTHON" - "$ROOT" <<'PY'
|
|
168
|
+
import sys, yaml
|
|
169
|
+
from pathlib import Path
|
|
170
|
+
|
|
171
|
+
root = Path(sys.argv[1])
|
|
172
|
+
external_dir = root / ".claude" / "agents" / "external"
|
|
173
|
+
errors = []
|
|
174
|
+
|
|
175
|
+
for manifest_path in sorted(external_dir.glob("*.manifest.yaml")):
|
|
176
|
+
docs = list(yaml.safe_load_all(manifest_path.read_text(encoding="utf-8")))
|
|
177
|
+
data = docs[0] if docs else {}
|
|
178
|
+
if not isinstance(data, dict):
|
|
179
|
+
continue
|
|
180
|
+
framework = manifest_path.stem.replace(".manifest", "")
|
|
181
|
+
prefix = data.get("prefix", framework)
|
|
182
|
+
imported = data.get("imported_agents", []) or []
|
|
183
|
+
target_dir = external_dir / framework / "agents"
|
|
184
|
+
expected_names = {f"{prefix}-{Path(agent.replace('.md', '')).name}.md" for agent in imported}
|
|
185
|
+
actual_names = {p.name for p in target_dir.glob("*.md")} if target_dir.exists() else set()
|
|
186
|
+
|
|
187
|
+
missing = expected_names - actual_names
|
|
188
|
+
extra = actual_names - expected_names
|
|
189
|
+
if missing:
|
|
190
|
+
errors.append(f"{manifest_path.name}: missing imported agents: {sorted(missing)}")
|
|
191
|
+
if extra:
|
|
192
|
+
errors.append(f"{manifest_path.name}: unexpected agents on disk: {sorted(extra)}")
|
|
193
|
+
|
|
194
|
+
if errors:
|
|
195
|
+
print("External agent sync issues:")
|
|
196
|
+
for e in errors:
|
|
197
|
+
print(f" - {e}")
|
|
198
|
+
sys.exit(1)
|
|
199
|
+
print("All imported external agents are present.")
|
|
200
|
+
PY
|
|
201
|
+
|
|
166
202
|
echo "==> Validate Python syntax"
|
|
167
203
|
"$PYTHON" -m py_compile scripts/validate_stage.py
|
|
168
204
|
|
|
@@ -341,6 +377,9 @@ echo "==> Check plugin package manifest"
|
|
|
341
377
|
find .claude scripts tools -type d -name __pycache__ -prune -exec rm -rf {} + 2>/dev/null || true
|
|
342
378
|
"$PYTHON" scripts/package-lincoln-plugin.py check
|
|
343
379
|
|
|
380
|
+
echo "==> Check prompt drift (PM stages only; non-PM drift reported separately)"
|
|
381
|
+
"$PYTHON" scripts/check-prompt-drift.py --strict --focus pm
|
|
382
|
+
|
|
344
383
|
echo "==> Run pytest"
|
|
345
384
|
"$PYTHON" -m pytest tests/ -v
|
|
346
385
|
pytest_exit=$?
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sushanglewis/lincoln",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.5",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@sushanglewis/lincoln",
|
|
9
|
-
"version": "1.5.
|
|
9
|
+
"version": "1.5.5",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"ink": "^5.2.0",
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lincoln-install",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.5",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "lincoln-install",
|
|
9
|
-
"version": "1.5.
|
|
9
|
+
"version": "1.5.5",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"ink": "^5.2.0",
|