@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.
Files changed (35) hide show
  1. package/framework/.claude/agents/designer.md +43 -8
  2. package/framework/.claude/agents/external/NOTICES.md +1 -1
  3. package/framework/.claude/agents/external/everything-claude-code/agents/ecc-tdd-guide.md +108 -0
  4. package/framework/.claude/agents/external/everything-claude-code.manifest.yaml +1 -1
  5. package/framework/.claude/agents/external/oh-my-claudecode/agents/omc-architect.md +134 -0
  6. package/framework/.claude/agents/external/oh-my-claudecode/agents/omc-code-reviewer.md +248 -0
  7. package/framework/.claude/agents/external/oh-my-claudecode/agents/omc-designer.md +122 -0
  8. package/framework/.claude/agents/external/oh-my-claudecode/agents/omc-qa-tester.md +106 -0
  9. package/framework/.claude/agents/external/wshobson-agents/agents/wshobson-frontend-developer.md +173 -0
  10. package/framework/.claude/agents/frontend-engineer.md +3 -3
  11. package/framework/.claude/agents/pm.md +67 -14
  12. package/framework/.claude/agents/qa.md +1 -1
  13. package/framework/.claude/hooks/pre-tool-use.sh +3 -3
  14. package/framework/.claude/skills/build-product-prototype/SKILL.md +1 -1
  15. package/framework/.claude/skills/dependencies.yaml +6 -0
  16. package/framework/.claude/skills/draft-product-design/SKILL.md +2 -0
  17. package/framework/.claude/skills/draft-product-design/prompts/main.md +6 -5
  18. package/framework/.claude/skills/lc-workflow/validators/validate.py +2 -0
  19. package/framework/.claude/stages/clarify.yaml +1 -1
  20. package/framework/.claude/stages/product-design-docs.yaml +4 -2
  21. package/framework/.claude/stages/product-prototype.yaml +4 -3
  22. package/framework/.claude/workflows/README.md +25 -3
  23. package/framework/.claude/workflows/interview-to-knowledge.yaml +3 -2
  24. package/framework/.claude-plugin/marketplace.json +2 -2
  25. package/framework/.claude-plugin/plugin.json +1 -1
  26. package/framework/.version-bump.json +1 -1
  27. package/framework/manifest.json +56 -36
  28. package/framework/scripts/check-prompt-drift.py +526 -0
  29. package/framework/scripts/static-check.sh +39 -0
  30. package/framework/scripts/sync-external-agents.sh +2 -0
  31. package/framework/tools/lincoln/package-lock.json +2 -2
  32. package/framework/tools/lincoln/package.json +1 -1
  33. package/framework/tools/lincoln-installer/package-lock.json +2 -2
  34. package/framework/tools/lincoln-installer/package.json +1 -1
  35. 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=$?
@@ -19,6 +19,8 @@ AGENTS_DIR="$ROOT/.claude/agents/external"
19
19
  mkdir -p "$AGENTS_DIR"
20
20
 
21
21
  "$ROOT/.venv/bin/python3" - "$AGENTS_DIR" "$DRY_RUN" <<'PY'
22
+ from __future__ import annotations
23
+
22
24
  import sys
23
25
  from pathlib import Path
24
26
  import re
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@sushanglewis/lincoln",
3
- "version": "1.5.4",
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.4",
9
+ "version": "1.5.5",
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.5.4",
3
+ "version": "1.5.5",
4
4
  "description": "Cross-tool terminal UI for recording Lincoln interviews",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "lincoln-install",
3
- "version": "1.5.0",
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.0",
9
+ "version": "1.5.5",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "ink": "^5.2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lincoln-install",
3
- "version": "1.5.4",
3
+ "version": "1.5.5",
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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sushanglewis/lincoln",
3
- "version": "1.5.4",
3
+ "version": "1.5.5",
4
4
  "description": "Global Lincoln agent harness plugin CLI",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",