@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.
- package/dist/main.js +425 -51
- package/dist/main.js.map +1 -1
- package/dist/postinstall.d.ts +9 -2
- package/dist/postinstall.js +812 -6
- package/dist/postinstall.js.map +1 -1
- package/framework/.claude/agents/architect.md +15 -15
- package/framework/.claude/agents/default.md +118 -104
- package/framework/.claude/agents/designer.md +17 -17
- package/framework/.claude/agents/engineer.md +16 -25
- 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 +15 -16
- package/framework/.claude/agents/knowledge-curator.md +18 -15
- package/framework/.claude/agents/pm.md +17 -23
- package/framework/.claude/agents/qa.md +17 -13
- package/framework/.claude/agents/release-coordinator.md +18 -12
- package/framework/.claude/agents/researcher.md +16 -26
- package/framework/.claude/harnesses/command-map.yaml +12 -5
- package/framework/.claude/hooks/on-session-start.sh +0 -1
- package/framework/.claude/hooks/pre-tool-use.sh +3 -3
- package/framework/.claude/schemas/agent-definition.schema.json +6 -1
- package/framework/.claude/schemas/stage-definition.schema.json +49 -4
- package/framework/.claude/skills/build-product-prototype/SKILL.md +1 -1
- package/framework/.claude/skills/build-product-prototype/prompts/main.md +89 -26
- package/framework/.claude/skills/clarify-requirements/prompts/main.md +80 -14
- 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 +30 -17
- package/framework/.claude/skills/lc-build-codebase-knowledge/SKILL.md +2 -0
- package/framework/.claude/skills/lc-explore-opensource/SKILL.md +2 -0
- package/framework/.claude/skills/lc-market-research/SKILL.md +1 -1
- package/framework/.claude/skills/lc-one-id/SKILL.md +81 -0
- package/framework/.claude/skills/lc-one-id/prompts/main.md +35 -0
- package/framework/.claude/skills/lc-stakeholder-research/SKILL.md +1 -1
- package/framework/.claude/skills/lc-workflow/validators/validate.py +2 -0
- package/framework/.claude/skills/lc-workflow-router/SKILL.md +2 -0
- package/framework/.claude/stages/build-codebase-knowledge.yaml +10 -10
- package/framework/.claude/stages/clarify.yaml +22 -16
- package/framework/.claude/stages/explore-opensource.yaml +13 -10
- package/framework/.claude/stages/implement.yaml +11 -11
- package/framework/.claude/stages/ingest.yaml +13 -10
- package/framework/.claude/stages/lc-analyze-frameworks.yaml +13 -10
- package/framework/.claude/stages/lc-collect-intelligence.yaml +13 -10
- package/framework/.claude/stages/lc-competitive-analysis.yaml +13 -10
- package/framework/.claude/stages/lc-first-principles.yaml +13 -10
- package/framework/.claude/stages/lc-market-research.yaml +13 -10
- package/framework/.claude/stages/lc-product-research.yaml +13 -10
- package/framework/.claude/stages/lc-research-report.yaml +13 -10
- package/framework/.claude/stages/lc-research-scope.yaml +13 -10
- package/framework/.claude/stages/lc-stakeholder-research.yaml +13 -10
- package/framework/.claude/stages/lc-storytelling.yaml +13 -10
- package/framework/.claude/stages/product-design-docs.yaml +33 -19
- package/framework/.claude/stages/product-prototype.yaml +22 -18
- package/framework/.claude/stages/propose.yaml +16 -9
- package/framework/.claude/stages/split.yaml +13 -9
- package/framework/.claude/stages/sync-knowledge.yaml +20 -9
- package/framework/.claude/stages/tdd-development-plan.yaml +17 -9
- package/framework/.claude/stages/workflow-router.yaml +17 -13
- package/framework/.claude/workflows/README.md +25 -3
- package/framework/.claude/workflows/bug-fix.yaml +1 -0
- package/framework/.claude/workflows/existing-project-iteration.yaml +1 -0
- package/framework/.claude/workflows/interview-to-knowledge.yaml +4 -2
- package/framework/.claude/workflows/oss-first-design.yaml +1 -0
- package/framework/.claude-plugin/marketplace.json +2 -2
- package/framework/.claude-plugin/plugin.json +2 -1
- package/framework/.version-bump.json +1 -1
- package/framework/README.en.md +15 -2
- package/framework/README.md +16 -3
- package/framework/RELEASE.md +32 -0
- package/framework/manifest.json +128 -88
- package/framework/scripts/check-prompt-drift.py +657 -0
- package/framework/scripts/extract_release_notes.py +79 -0
- package/framework/scripts/init-lincoln-branch.sh +6 -0
- package/framework/scripts/lincoln_command_map.py +1 -1
- package/framework/scripts/lincoln_id.py +333 -0
- package/framework/scripts/lincoln_index.py +10 -0
- package/framework/scripts/lincoln_prd.py +16 -17
- package/framework/scripts/lincoln_render.py +657 -0
- package/framework/scripts/run-infrastructure-tests.py +2 -0
- package/framework/scripts/stage_loader.py +149 -2
- package/framework/scripts/static-check.sh +40 -1
- package/framework/scripts/sync-external-agents.sh +2 -0
- package/framework/scripts/update_readme_version.py +149 -0
- package/framework/scripts/validate_stage.py +80 -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 +1 -1
- package/package.json +2 -2
- 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())
|