@sushanglewis/lincoln 1.5.4 → 1.6.1

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 (95) hide show
  1. package/dist/main.js +425 -51
  2. package/dist/main.js.map +1 -1
  3. package/dist/postinstall.d.ts +9 -2
  4. package/dist/postinstall.js +812 -6
  5. package/dist/postinstall.js.map +1 -1
  6. package/framework/.claude/agents/architect.md +15 -15
  7. package/framework/.claude/agents/default.md +118 -104
  8. package/framework/.claude/agents/designer.md +17 -17
  9. package/framework/.claude/agents/engineer.md +16 -25
  10. package/framework/.claude/agents/external/NOTICES.md +1 -1
  11. package/framework/.claude/agents/external/everything-claude-code/agents/ecc-tdd-guide.md +108 -0
  12. package/framework/.claude/agents/external/everything-claude-code.manifest.yaml +1 -1
  13. package/framework/.claude/agents/external/oh-my-claudecode/agents/omc-architect.md +134 -0
  14. package/framework/.claude/agents/external/oh-my-claudecode/agents/omc-code-reviewer.md +248 -0
  15. package/framework/.claude/agents/external/oh-my-claudecode/agents/omc-designer.md +122 -0
  16. package/framework/.claude/agents/external/oh-my-claudecode/agents/omc-qa-tester.md +106 -0
  17. package/framework/.claude/agents/external/wshobson-agents/agents/wshobson-frontend-developer.md +173 -0
  18. package/framework/.claude/agents/frontend-engineer.md +15 -16
  19. package/framework/.claude/agents/knowledge-curator.md +18 -15
  20. package/framework/.claude/agents/pm.md +17 -23
  21. package/framework/.claude/agents/qa.md +17 -13
  22. package/framework/.claude/agents/release-coordinator.md +18 -12
  23. package/framework/.claude/agents/researcher.md +16 -26
  24. package/framework/.claude/harnesses/command-map.yaml +12 -5
  25. package/framework/.claude/hooks/on-session-start.sh +0 -1
  26. package/framework/.claude/hooks/pre-tool-use.sh +3 -3
  27. package/framework/.claude/schemas/agent-definition.schema.json +6 -1
  28. package/framework/.claude/schemas/stage-definition.schema.json +49 -4
  29. package/framework/.claude/skills/build-product-prototype/SKILL.md +1 -1
  30. package/framework/.claude/skills/build-product-prototype/prompts/main.md +89 -26
  31. package/framework/.claude/skills/clarify-requirements/prompts/main.md +80 -14
  32. package/framework/.claude/skills/dependencies.yaml +6 -0
  33. package/framework/.claude/skills/draft-product-design/SKILL.md +2 -0
  34. package/framework/.claude/skills/draft-product-design/prompts/main.md +30 -17
  35. package/framework/.claude/skills/lc-build-codebase-knowledge/SKILL.md +2 -0
  36. package/framework/.claude/skills/lc-explore-opensource/SKILL.md +2 -0
  37. package/framework/.claude/skills/lc-market-research/SKILL.md +1 -1
  38. package/framework/.claude/skills/lc-one-id/SKILL.md +81 -0
  39. package/framework/.claude/skills/lc-one-id/prompts/main.md +35 -0
  40. package/framework/.claude/skills/lc-stakeholder-research/SKILL.md +1 -1
  41. package/framework/.claude/skills/lc-workflow/validators/validate.py +2 -0
  42. package/framework/.claude/skills/lc-workflow-router/SKILL.md +2 -0
  43. package/framework/.claude/stages/build-codebase-knowledge.yaml +10 -10
  44. package/framework/.claude/stages/clarify.yaml +22 -16
  45. package/framework/.claude/stages/explore-opensource.yaml +13 -10
  46. package/framework/.claude/stages/implement.yaml +11 -11
  47. package/framework/.claude/stages/ingest.yaml +13 -10
  48. package/framework/.claude/stages/lc-analyze-frameworks.yaml +13 -10
  49. package/framework/.claude/stages/lc-collect-intelligence.yaml +13 -10
  50. package/framework/.claude/stages/lc-competitive-analysis.yaml +13 -10
  51. package/framework/.claude/stages/lc-first-principles.yaml +13 -10
  52. package/framework/.claude/stages/lc-market-research.yaml +13 -10
  53. package/framework/.claude/stages/lc-product-research.yaml +13 -10
  54. package/framework/.claude/stages/lc-research-report.yaml +13 -10
  55. package/framework/.claude/stages/lc-research-scope.yaml +13 -10
  56. package/framework/.claude/stages/lc-stakeholder-research.yaml +13 -10
  57. package/framework/.claude/stages/lc-storytelling.yaml +13 -10
  58. package/framework/.claude/stages/product-design-docs.yaml +33 -19
  59. package/framework/.claude/stages/product-prototype.yaml +22 -18
  60. package/framework/.claude/stages/propose.yaml +16 -9
  61. package/framework/.claude/stages/split.yaml +13 -9
  62. package/framework/.claude/stages/sync-knowledge.yaml +20 -9
  63. package/framework/.claude/stages/tdd-development-plan.yaml +17 -9
  64. package/framework/.claude/stages/workflow-router.yaml +17 -13
  65. package/framework/.claude/workflows/README.md +25 -3
  66. package/framework/.claude/workflows/bug-fix.yaml +1 -0
  67. package/framework/.claude/workflows/existing-project-iteration.yaml +1 -0
  68. package/framework/.claude/workflows/interview-to-knowledge.yaml +4 -2
  69. package/framework/.claude/workflows/oss-first-design.yaml +1 -0
  70. package/framework/.claude-plugin/marketplace.json +2 -2
  71. package/framework/.claude-plugin/plugin.json +2 -1
  72. package/framework/.version-bump.json +1 -1
  73. package/framework/README.en.md +15 -2
  74. package/framework/README.md +16 -3
  75. package/framework/RELEASE.md +32 -0
  76. package/framework/manifest.json +128 -88
  77. package/framework/scripts/check-prompt-drift.py +657 -0
  78. package/framework/scripts/extract_release_notes.py +79 -0
  79. package/framework/scripts/init-lincoln-branch.sh +6 -0
  80. package/framework/scripts/lincoln_command_map.py +1 -1
  81. package/framework/scripts/lincoln_id.py +333 -0
  82. package/framework/scripts/lincoln_index.py +10 -0
  83. package/framework/scripts/lincoln_prd.py +16 -17
  84. package/framework/scripts/lincoln_render.py +657 -0
  85. package/framework/scripts/run-infrastructure-tests.py +2 -0
  86. package/framework/scripts/stage_loader.py +149 -2
  87. package/framework/scripts/static-check.sh +40 -1
  88. package/framework/scripts/sync-external-agents.sh +2 -0
  89. package/framework/scripts/update_readme_version.py +149 -0
  90. package/framework/scripts/validate_stage.py +80 -1
  91. package/framework/tools/lincoln/package-lock.json +2 -2
  92. package/framework/tools/lincoln/package.json +1 -1
  93. package/framework/tools/lincoln-installer/package.json +1 -1
  94. package/package.json +2 -2
  95. package/framework/.claude/agents/_contract.md +0 -79
@@ -0,0 +1,657 @@
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
+ Steps whose action does not resolve to a local skill are treated as
8
+ human-driven action labels (e.g. 'implement') and skipped.
9
+ R3: workflow step artifacts must match stage artifacts.
10
+ R4: primary skill outputs must match stage artifacts.
11
+ R5: agent artifact paths must be a subset of stage artifacts.
12
+ R6: stage skill references must be declared in dependencies or local skills.
13
+ R7: every path referenced in stage artifact_templates must exist on disk.
14
+ R8: stage primary_action must match the action of its workflow step in every
15
+ workflow that includes the stage.
16
+ R9: stage workflows must be an explicit list of workflow IDs (never 'all'),
17
+ and every listed ID must correspond to an existing .claude/workflows/*.yaml.
18
+
19
+ Note: the stage YAML field formerly named `templates` was renamed to
20
+ `workflows` in stage schema 3.0.0; these rules reference the new field name.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import re
27
+ import sys
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ import yaml
32
+
33
+ ROOT = Path(__file__).resolve().parents[1]
34
+ AGENTS_DIR = ROOT / ".claude" / "agents"
35
+ STAGES_DIR = ROOT / ".claude" / "stages"
36
+ WORKFLOWS_DIR = ROOT / ".claude" / "workflows"
37
+ SKILLS_DIR = ROOT / ".claude" / "skills"
38
+ DEP_FILE = ROOT / ".claude" / "skills" / "dependencies.yaml"
39
+
40
+ PM_STAGES = {"clarify", "product-design-docs", "product-prototype"}
41
+
42
+
43
+ def parse_frontmatter(path: Path) -> dict[str, Any]:
44
+ text = path.read_text(encoding="utf-8")
45
+ if not text.startswith("---"):
46
+ return {}
47
+ end = text.find("---", 3)
48
+ if end == -1:
49
+ return {}
50
+ try:
51
+ return yaml.safe_load(text[3:end]) or {}
52
+ except yaml.YAMLError:
53
+ return {}
54
+
55
+
56
+ def load_yaml(path: Path) -> Any:
57
+ return yaml.safe_load(path.read_text(encoding="utf-8"))
58
+
59
+
60
+ def normalize_path(path: str) -> str:
61
+ """Strip trailing slashes and collapse placeholders for comparison."""
62
+ return path.rstrip("/")
63
+
64
+
65
+ def glob_to_regex(pattern: str) -> re.Pattern[str]:
66
+ """Convert a simple glob pattern with * and ** to a regex."""
67
+ parts = []
68
+ i = 0
69
+ while i < len(pattern):
70
+ if pattern[i : i + 2] == "**":
71
+ parts.append(".*")
72
+ i += 2
73
+ elif pattern[i] == "*":
74
+ parts.append("[^/]*")
75
+ i += 1
76
+ else:
77
+ parts.append(re.escape(pattern[i]))
78
+ i += 1
79
+ return re.compile("^" + "".join(parts) + "$")
80
+
81
+
82
+ def paths_match(a: str, b: str) -> bool:
83
+ """Compare two artifact paths, treating globs specially."""
84
+ a = normalize_path(a)
85
+ b = normalize_path(b)
86
+ if a == b:
87
+ return True
88
+ # If one is a glob, see if the other matches.
89
+ if "*" in a:
90
+ return bool(glob_to_regex(a).match(b))
91
+ if "*" in b:
92
+ return bool(glob_to_regex(b).match(a))
93
+ return False
94
+
95
+
96
+ def path_in_set(path: str, path_set: set[str]) -> bool:
97
+ for candidate in path_set:
98
+ if paths_match(path, candidate):
99
+ return True
100
+ return False
101
+
102
+
103
+ def collect_agents() -> dict[str, dict[str, Any]]:
104
+ agents: dict[str, dict[str, Any]] = {}
105
+ for path in sorted(AGENTS_DIR.glob("*.md")):
106
+ front = parse_frontmatter(path)
107
+ agents[path.name] = {
108
+ "path": path,
109
+ "front": front,
110
+ "body": path.read_text(encoding="utf-8"),
111
+ }
112
+ return agents
113
+
114
+
115
+ def collect_stages() -> dict[str, dict[str, Any]]:
116
+ stages: dict[str, dict[str, Any]] = {}
117
+ for path in sorted(STAGES_DIR.glob("*.yaml")):
118
+ if path.name == "stage-manifest.yaml":
119
+ continue
120
+ data = load_yaml(path)
121
+ if isinstance(data, dict):
122
+ stages[data.get("id", path.stem)] = {"path": path, "data": data}
123
+ return stages
124
+
125
+
126
+ def collect_workflows() -> dict[str, dict[str, Any]]:
127
+ workflows: dict[str, dict[str, Any]] = {}
128
+ for path in sorted(WORKFLOWS_DIR.glob("*.yaml")):
129
+ data = load_yaml(path)
130
+ if isinstance(data, dict) and "workflow" in data:
131
+ workflows[data["workflow"].get("name", path.stem)] = {"path": path, "data": data["workflow"]}
132
+ return workflows
133
+
134
+
135
+ def collect_skills() -> dict[str, dict[str, Any]]:
136
+ skills: dict[str, dict[str, Any]] = {}
137
+ for path in sorted(SKILLS_DIR.glob("*/SKILL.md")):
138
+ front = parse_frontmatter(path)
139
+ skills[path.parent.name] = {"path": path, "front": front}
140
+ return skills
141
+
142
+
143
+ def collect_dependencies() -> set[str]:
144
+ declared: set[str] = set()
145
+ if not DEP_FILE.exists():
146
+ return declared
147
+ data = load_yaml(DEP_FILE)
148
+ if not isinstance(data, dict):
149
+ return declared
150
+ for namespace, info in (data.get("skills") or {}).items():
151
+ if not isinstance(info, dict):
152
+ continue
153
+ # Local inline skills are implicitly declared.
154
+ if info.get("source") == "inline" or info.get("type") == "inline":
155
+ declared.add(namespace)
156
+ continue
157
+ # Register the namespace itself so any namespaced ref under it is acceptable.
158
+ declared.add(namespace)
159
+ for skill in info.get("required_skills", []) or []:
160
+ declared.add(normalize_skill_ref(namespace, skill))
161
+ for skill in info.get("recommended_skills", []) or []:
162
+ declared.add(normalize_skill_ref(namespace, skill))
163
+ return declared
164
+
165
+
166
+ def normalize_skill_ref(namespace: str, skill: str) -> str:
167
+ """Return a canonical namespaced skill reference.
168
+
169
+ dependencies.yaml mixes formats: some entries are already namespaced
170
+ ("superpowers:brainstorming", "oh-my-claudecode:plan") while others use
171
+ a prefixed bare name ("gsd-import"). Normalize to "namespace:name".
172
+ """
173
+ skill = skill.strip()
174
+ if skill.startswith(f"{namespace}:"):
175
+ return skill
176
+ if skill.startswith(f"{namespace}-"):
177
+ return f"{namespace}:{skill[len(namespace) + 1:]}"
178
+ return f"{namespace}:{skill}"
179
+
180
+
181
+ def collect_local_skill_names() -> set[str]:
182
+ return {p.parent.name for p in SKILLS_DIR.glob("*/SKILL.md")}
183
+
184
+
185
+ def artifact_paths(stage: dict[str, Any]) -> tuple[set[str], set[str]]:
186
+ required = set()
187
+ optional = set()
188
+ artifacts = stage.get("artifacts", {})
189
+ for p in artifacts.get("required", []):
190
+ required.add(normalize_path(str(p)))
191
+ for p in artifacts.get("optional", []):
192
+ optional.add(normalize_path(str(p)))
193
+ return required, optional
194
+
195
+
196
+ def check_r1_extends_resolve(agents: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
197
+ findings = []
198
+ for name, info in agents.items():
199
+ for extends_ref in info["front"].get("extends", []) or []:
200
+ resolved = ROOT / ".claude" / extends_ref
201
+ if not resolved.exists():
202
+ findings.append({
203
+ "rule": "R1",
204
+ "severity": "error",
205
+ "stage_focus": False,
206
+ "file": f".claude/agents/{name}",
207
+ "message": f"extends '{extends_ref}' does not resolve to an existing file",
208
+ })
209
+ return findings
210
+
211
+
212
+ def check_r2_action_in_stage_skills(
213
+ workflows: dict[str, dict[str, Any]],
214
+ stages: dict[str, dict[str, Any]],
215
+ local_skills: set[str] | None = None,
216
+ ) -> list[dict[str, Any]]:
217
+ local_skills = local_skills or set()
218
+ findings = []
219
+ for wf_name, wf_info in workflows.items():
220
+ for step in wf_info["data"].get("steps", []) or []:
221
+ stage_id = step.get("id")
222
+ action = step.get("action")
223
+ if not stage_id or not action:
224
+ continue
225
+ # Human-driven steps use a non-skill action label (e.g. 'implement').
226
+ # R2 only applies when the action resolves to a local skill.
227
+ if action not in local_skills:
228
+ continue
229
+ stage = stages.get(stage_id)
230
+ if not stage:
231
+ continue
232
+ stage_name = stage["data"].get("id", stage_id)
233
+ required_skills = stage["data"].get("skills", {}).get("required", []) or []
234
+ # Workflow actions map to either local skills (<action>) or namespaced skills.
235
+ possible_ids = {action, f"lincoln:{action}"}
236
+ if not any(skill in possible_ids for skill in required_skills):
237
+ findings.append({
238
+ "rule": "R2",
239
+ "severity": "error",
240
+ "stage_focus": stage_name in PM_STAGES,
241
+ "file": f".claude/workflows/{wf_name}.yaml",
242
+ "message": (
243
+ f"step '{stage_id}' action '{action}' is not listed in "
244
+ f"stage '{stage_id}' skills.required: {required_skills}"
245
+ ),
246
+ })
247
+ return findings
248
+
249
+
250
+ PM_CANONICAL_WORKFLOW = "interview-to-knowledge"
251
+
252
+
253
+ def check_r3_workflow_artifacts_match_stage(
254
+ workflows: dict[str, dict[str, Any]],
255
+ stages: dict[str, dict[str, Any]],
256
+ ) -> list[dict[str, Any]]:
257
+ findings = []
258
+ for wf_name, wf_info in workflows.items():
259
+ # For PM stages, only the canonical workflow is held to strict artifact equality.
260
+ # Other workflows may intentionally use a subset of PM stage artifacts.
261
+ is_pm_canonical = wf_name == PM_CANONICAL_WORKFLOW
262
+ for step in wf_info["data"].get("steps", []) or []:
263
+ stage_id = step.get("id")
264
+ if not stage_id:
265
+ continue
266
+ stage = stages.get(stage_id)
267
+ if not stage:
268
+ continue
269
+ stage_name = stage["data"].get("id", stage_id)
270
+ is_pm_stage = stage_name in PM_STAGES
271
+ stage_required, stage_optional = artifact_paths(stage["data"])
272
+
273
+ wf_required = {normalize_path(p) for p in step.get("artifacts", []) or []}
274
+ wf_optional = {normalize_path(p) for p in step.get("optional_artifacts", []) or []}
275
+
276
+ severity = "error" if (is_pm_stage and is_pm_canonical) else "warning"
277
+
278
+ # Compare required sets modulo glob matching.
279
+ for p in wf_required:
280
+ if not path_in_set(p, stage_required):
281
+ findings.append({
282
+ "rule": "R3",
283
+ "severity": severity,
284
+ "stage_focus": is_pm_stage,
285
+ "file": f".claude/workflows/{wf_name}.yaml",
286
+ "message": f"step '{stage_id}' required artifact '{p}' missing from stage '{stage_id}' required artifacts",
287
+ })
288
+ for p in stage_required:
289
+ if not path_in_set(p, wf_required):
290
+ findings.append({
291
+ "rule": "R3",
292
+ "severity": severity,
293
+ "stage_focus": is_pm_stage,
294
+ "file": f".claude/stages/{stage_id}.yaml",
295
+ "message": f"stage '{stage_id}' required artifact '{p}' missing from workflow '{wf_name}' step artifacts",
296
+ })
297
+
298
+ for p in wf_optional:
299
+ if not (path_in_set(p, stage_optional) or path_in_set(p, stage_required)):
300
+ findings.append({
301
+ "rule": "R3",
302
+ "severity": "warning",
303
+ "stage_focus": is_pm_stage,
304
+ "file": f".claude/workflows/{wf_name}.yaml",
305
+ "message": f"step '{stage_id}' optional artifact '{p}' not declared in stage '{stage_id}' artifacts",
306
+ })
307
+ for p in stage_optional:
308
+ if not path_in_set(p, wf_optional):
309
+ findings.append({
310
+ "rule": "R3",
311
+ "severity": "warning",
312
+ "stage_focus": is_pm_stage,
313
+ "file": f".claude/stages/{stage_id}.yaml",
314
+ "message": f"stage '{stage_id}' optional artifact '{p}' missing from workflow '{wf_name}' optional_artifacts",
315
+ })
316
+ return findings
317
+
318
+
319
+ def check_r4_skill_outputs_match_stage(
320
+ workflows: dict[str, dict[str, Any]],
321
+ stages: dict[str, dict[str, Any]],
322
+ skills: dict[str, dict[str, Any]],
323
+ ) -> list[dict[str, Any]]:
324
+ findings = []
325
+ for wf_name, wf_info in workflows.items():
326
+ for step in wf_info["data"].get("steps", []) or []:
327
+ stage_id = step.get("id")
328
+ action = step.get("action")
329
+ if not stage_id or not action:
330
+ continue
331
+ stage = stages.get(stage_id)
332
+ if not stage:
333
+ continue
334
+ stage_name = stage["data"].get("id", stage_id)
335
+ stage_required, stage_optional = artifact_paths(stage["data"])
336
+
337
+ # Find the primary skill by action name.
338
+ primary_skill = skills.get(action)
339
+ if primary_skill is None:
340
+ # Also try namespaced form.
341
+ for key, val in skills.items():
342
+ if val["front"].get("name") == action or val["front"].get("name") == f"lincoln:{action}":
343
+ primary_skill = val
344
+ break
345
+ if primary_skill is None:
346
+ continue
347
+
348
+ outputs = {normalize_path(p) for p in primary_skill["front"].get("outputs", []) or []}
349
+ all_stage_artifacts = stage_required | stage_optional
350
+ for p in outputs:
351
+ if not path_in_set(p, all_stage_artifacts):
352
+ findings.append({
353
+ "rule": "R4",
354
+ "severity": "warning",
355
+ "stage_focus": stage_name in PM_STAGES,
356
+ "file": str(primary_skill["path"].relative_to(ROOT)),
357
+ "message": f"skill output '{p}' not declared in stage '{stage_id}' artifacts",
358
+ })
359
+ for p in stage_required:
360
+ if not path_in_set(p, outputs):
361
+ findings.append({
362
+ "rule": "R4",
363
+ "severity": "error",
364
+ "stage_focus": stage_name in PM_STAGES,
365
+ "file": f".claude/stages/{stage_id}.yaml",
366
+ "message": f"stage '{stage_id}' required artifact '{p}' missing from skill '{action}' outputs",
367
+ })
368
+ return findings
369
+
370
+
371
+ def extract_output_sections(body: str) -> list[str]:
372
+ """Return text blocks under output/artifact sections in agent markdown."""
373
+ sections = []
374
+ # Match headings like ## 产物规范, ## Outputs, ## 产物, ### `clarify` 阶段
375
+ heading_re = re.compile(r"^(#{2,4}\s+(?:产物规范|Outputs|产物|Output).*)$", re.MULTILINE | re.IGNORECASE)
376
+ for match in heading_re.finditer(body):
377
+ start = match.end()
378
+ # Find the next heading of same or higher level
379
+ next_heading = re.search(r"^#{1,4}\s+", body[start:], re.MULTILINE)
380
+ end = start + next_heading.start() if next_heading else len(body)
381
+ sections.append(body[start:end])
382
+ return sections
383
+
384
+
385
+ def extract_artifact_paths(body: str) -> set[str]:
386
+ """Extract likely artifact output paths from agent markdown body.
387
+
388
+ Only scans sections that describe outputs/artifacts to avoid flagging
389
+ input references (e.g., handoff files read by designer).
390
+ """
391
+ paths: set[str] = set()
392
+ sections = extract_output_sections(body)
393
+ if not sections:
394
+ sections = [body]
395
+ for section in sections:
396
+ for match in re.finditer(r"`(\{process_slug\}/[^`]+)`", section):
397
+ candidate = match.group(1).strip("`")
398
+ if "/" in candidate:
399
+ paths.add(normalize_path(candidate))
400
+ for match in re.finditer(r"(?:^|\s)(\{process_slug\}/[\w\-./{}*]+)", section):
401
+ candidate = match.group(1).strip()
402
+ if "/" in candidate and not candidate.endswith(":"):
403
+ paths.add(normalize_path(candidate))
404
+ return paths
405
+
406
+
407
+ def check_r5_agent_paths_in_stage_artifacts(
408
+ agents: dict[str, dict[str, Any]],
409
+ stages: dict[str, dict[str, Any]],
410
+ ) -> list[dict[str, Any]]:
411
+ findings = []
412
+ for agent_name, info in agents.items():
413
+ front = info["front"]
414
+ agent_name_attr = front.get("name", agent_name)
415
+ paths = extract_artifact_paths(info["body"])
416
+ if not paths:
417
+ continue
418
+
419
+ # Find all stages where this agent is primary and collect their artifacts.
420
+ agent_stage_ids = []
421
+ all_stage_artifacts: set[str] = set()
422
+ for stage_id, stage in stages.items():
423
+ primary = stage["data"].get("agent", {}).get("primary", "")
424
+ if primary != agent_name_attr:
425
+ continue
426
+ agent_stage_ids.append(stage_id)
427
+ required, optional = artifact_paths(stage["data"])
428
+ all_stage_artifacts |= required | optional
429
+
430
+ if not agent_stage_ids:
431
+ continue
432
+
433
+ for p in paths:
434
+ if not path_in_set(p, all_stage_artifacts):
435
+ findings.append({
436
+ "rule": "R5",
437
+ "severity": "error",
438
+ "stage_focus": any(sid in PM_STAGES for sid in agent_stage_ids),
439
+ "file": f".claude/agents/{agent_name}",
440
+ "message": f"agent '{agent_name_attr}' references artifact '{p}' not declared in any of its primary stages ({', '.join(agent_stage_ids)}) artifacts",
441
+ })
442
+ return findings
443
+
444
+
445
+ def check_r6_skill_dependencies_declared(
446
+ stages: dict[str, dict[str, Any]],
447
+ declared: set[str],
448
+ local_skills: set[str],
449
+ ) -> list[dict[str, Any]]:
450
+ findings = []
451
+ for stage_id, stage in stages.items():
452
+ skills_block = stage["data"].get("skills", {})
453
+ all_refs = list(skills_block.get("required", []) or []) + list(skills_block.get("optional", []) or [])
454
+ for ref in all_refs:
455
+ if ":" not in ref:
456
+ # Bare local skill name.
457
+ if ref not in local_skills:
458
+ findings.append({
459
+ "rule": "R6",
460
+ "severity": "error",
461
+ "stage_focus": stage_id in PM_STAGES,
462
+ "file": f".claude/stages/{stage_id}.yaml",
463
+ "message": f"skill '{ref}' not declared in dependencies.yaml and not a local skill",
464
+ })
465
+ continue
466
+ namespace, skill_name = ref.split(":", 1)
467
+ # Local inline namespace like lincoln:*.
468
+ if namespace in local_skills or namespace == "lincoln":
469
+ # lincoln:* names are mapped to local skill directory names.
470
+ if namespace != "lincoln" and namespace in local_skills:
471
+ continue
472
+ if skill_name in local_skills:
473
+ continue
474
+ # Some lincoln skills use hyphenated names in dirs (e.g., lc-stage).
475
+ if skill_name.replace("-", "_") in {s.replace("-", "_") for s in local_skills}:
476
+ continue
477
+ if ref in declared:
478
+ continue
479
+ # Also accept namespace-only declaration for gsd/superpowers.
480
+ if namespace in declared and f"{namespace}:{skill_name}" not in declared:
481
+ continue
482
+ findings.append({
483
+ "rule": "R6",
484
+ "severity": "error",
485
+ "stage_focus": stage_id in PM_STAGES,
486
+ "file": f".claude/stages/{stage_id}.yaml",
487
+ "message": f"skill '{ref}' not declared in dependencies.yaml or local skills",
488
+ })
489
+ return findings
490
+
491
+
492
+ def check_r7_artifact_templates_exist(stages: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
493
+ """Every template file referenced in stage artifact_templates must exist on disk."""
494
+ findings = []
495
+ for stage_id, stage in stages.items():
496
+ artifact_templates = stage["data"].get("artifact_templates") or {}
497
+ if not isinstance(artifact_templates, dict):
498
+ findings.append({
499
+ "rule": "R7",
500
+ "severity": "error",
501
+ "stage_focus": stage_id in PM_STAGES,
502
+ "file": f".claude/stages/{stage_id}.yaml",
503
+ "message": "artifact_templates must be a mapping of artifact path to template file",
504
+ })
505
+ continue
506
+ for artifact_path, template_path in artifact_templates.items():
507
+ template_str = str(template_path)
508
+ # Paths with placeholders/globs are resolved at runtime; skip them here.
509
+ if "{" in template_str or "*" in template_str:
510
+ continue
511
+ if not (ROOT / template_str).exists():
512
+ findings.append({
513
+ "rule": "R7",
514
+ "severity": "error",
515
+ "stage_focus": stage_id in PM_STAGES,
516
+ "file": f".claude/stages/{stage_id}.yaml",
517
+ "message": (
518
+ f"artifact_templates entry '{artifact_path}' references template "
519
+ f"'{template_str}' which does not exist on disk"
520
+ ),
521
+ })
522
+ return findings
523
+
524
+
525
+ def check_r8_primary_action_matches_workflow(
526
+ workflows: dict[str, dict[str, Any]],
527
+ stages: dict[str, dict[str, Any]],
528
+ ) -> list[dict[str, Any]]:
529
+ """Stage primary_action must match the step action in every workflow that includes it."""
530
+ findings = []
531
+ for stage_id, stage in stages.items():
532
+ primary_action = stage["data"].get("primary_action")
533
+ if not primary_action:
534
+ continue
535
+ for wf_name, wf_info in workflows.items():
536
+ for step in wf_info["data"].get("steps", []) or []:
537
+ if step.get("id") != stage_id:
538
+ continue
539
+ action = step.get("action")
540
+ if action != primary_action:
541
+ findings.append({
542
+ "rule": "R8",
543
+ "severity": "error",
544
+ "stage_focus": stage_id in PM_STAGES,
545
+ "file": f".claude/workflows/{wf_name}.yaml",
546
+ "message": (
547
+ f"step '{stage_id}' action '{action}' does not match stage "
548
+ f"'{stage_id}' primary_action '{primary_action}'"
549
+ ),
550
+ })
551
+ return findings
552
+
553
+
554
+ def check_r9_workflows_explicit(
555
+ stages: dict[str, dict[str, Any]],
556
+ workflow_ids: set[str],
557
+ ) -> list[dict[str, Any]]:
558
+ """Stage workflows must be an explicit list of existing workflow IDs, never 'all'."""
559
+ findings = []
560
+ for stage_id, stage in stages.items():
561
+ data = stage["data"]
562
+ if "workflows" not in data:
563
+ # Stage YAML not yet migrated to schema 3.0.0; required-field
564
+ # enforcement lives in the stage schema/static checks.
565
+ continue
566
+ workflows_field = data.get("workflows")
567
+ if isinstance(workflows_field, str) or workflows_field is None:
568
+ findings.append({
569
+ "rule": "R9",
570
+ "severity": "error",
571
+ "stage_focus": stage_id in PM_STAGES,
572
+ "file": f".claude/stages/{stage_id}.yaml",
573
+ "message": (
574
+ f"workflows must be an explicit list of workflow IDs, got "
575
+ f"'{workflows_field}' ('all' is not allowed)"
576
+ ),
577
+ })
578
+ continue
579
+ if not isinstance(workflows_field, list) or not workflows_field:
580
+ findings.append({
581
+ "rule": "R9",
582
+ "severity": "error",
583
+ "stage_focus": stage_id in PM_STAGES,
584
+ "file": f".claude/stages/{stage_id}.yaml",
585
+ "message": "workflows must be a non-empty list of workflow IDs",
586
+ })
587
+ continue
588
+ for workflow_id in workflows_field:
589
+ if workflow_id not in workflow_ids:
590
+ findings.append({
591
+ "rule": "R9",
592
+ "severity": "error",
593
+ "stage_focus": stage_id in PM_STAGES,
594
+ "file": f".claude/stages/{stage_id}.yaml",
595
+ "message": (
596
+ f"workflows entry '{workflow_id}' does not correspond to an "
597
+ f"existing .claude/workflows/{workflow_id}.yaml"
598
+ ),
599
+ })
600
+ return findings
601
+
602
+
603
+ def main() -> int:
604
+ parser = argparse.ArgumentParser(description="Check Lincoln prompt drift")
605
+ parser.add_argument("--strict", action="store_true", help="Fail if any PM-stage drift is found")
606
+ parser.add_argument("--focus", choices=["all", "pm"], default="all", help="Focus on PM stages only")
607
+ args = parser.parse_args()
608
+
609
+ agents = collect_agents()
610
+ stages = collect_stages()
611
+ workflows = collect_workflows()
612
+ skills = collect_skills()
613
+ declared = collect_dependencies()
614
+ local_skills = collect_local_skill_names()
615
+ workflow_ids = {p.stem for p in WORKFLOWS_DIR.glob("*.yaml")}
616
+
617
+ findings: list[dict[str, Any]] = []
618
+ findings.extend(check_r1_extends_resolve(agents))
619
+ findings.extend(check_r2_action_in_stage_skills(workflows, stages, local_skills))
620
+ findings.extend(check_r3_workflow_artifacts_match_stage(workflows, stages))
621
+ findings.extend(check_r4_skill_outputs_match_stage(workflows, stages, skills))
622
+ findings.extend(check_r5_agent_paths_in_stage_artifacts(agents, stages))
623
+ findings.extend(check_r6_skill_dependencies_declared(stages, declared, local_skills))
624
+ findings.extend(check_r7_artifact_templates_exist(stages))
625
+ findings.extend(check_r8_primary_action_matches_workflow(workflows, stages))
626
+ findings.extend(check_r9_workflows_explicit(stages, workflow_ids))
627
+
628
+ # Deduplicate by (rule, file, message).
629
+ seen = set()
630
+ unique_findings = []
631
+ for f in findings:
632
+ key = (f["rule"], f["file"], f["message"])
633
+ if key not in seen:
634
+ seen.add(key)
635
+ unique_findings.append(f)
636
+ findings = unique_findings
637
+
638
+ if args.focus == "pm":
639
+ findings = [f for f in findings if f.get("stage_focus")]
640
+
641
+ errors = [f for f in findings if f["severity"] == "error"]
642
+ warnings = [f for f in findings if f["severity"] == "warning"]
643
+
644
+ for f in findings:
645
+ prefix = "ERROR" if f["severity"] == "error" else "WARN"
646
+ focus = " [PM]" if f.get("stage_focus") else ""
647
+ print(f"{prefix}{focus} {f['rule']} {f['file']}: {f['message']}")
648
+
649
+ print(f"\n{len(errors)} error(s), {len(warnings)} warning(s)")
650
+
651
+ if args.strict:
652
+ return 1 if errors else 0
653
+ return 0
654
+
655
+
656
+ if __name__ == "__main__":
657
+ sys.exit(main())