@sushanglewis/lincoln 1.5.0 → 1.5.2

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 (305) hide show
  1. package/dist/main.d.ts +3 -0
  2. package/dist/main.js +1286 -916
  3. package/dist/main.js.map +1 -1
  4. package/dist/postinstall.d.ts +3 -0
  5. package/dist/postinstall.js +18 -0
  6. package/dist/postinstall.js.map +1 -0
  7. package/framework/.claude/agents/_contract.md +79 -0
  8. package/framework/.claude/agents/architect.md +31 -0
  9. package/framework/.claude/agents/default.md +151 -0
  10. package/framework/.claude/agents/designer.md +33 -0
  11. package/framework/.claude/agents/engineer.md +42 -0
  12. package/framework/.claude/agents/external/NOTICES.md +12 -0
  13. package/framework/.claude/agents/external/everything-claude-code.manifest.yaml +23 -0
  14. package/framework/.claude/agents/external/oh-my-claudecode.manifest.yaml +26 -0
  15. package/framework/.claude/agents/external/wshobson-agents.manifest.yaml +23 -0
  16. package/framework/.claude/agents/frontend-engineer.md +32 -0
  17. package/framework/.claude/agents/knowledge-curator.md +28 -0
  18. package/framework/.claude/agents/pm.md +38 -0
  19. package/framework/.claude/agents/qa.md +28 -0
  20. package/framework/.claude/agents/release-coordinator.md +25 -0
  21. package/framework/.claude/agents/researcher.md +41 -0
  22. package/framework/.claude/harnesses/claude-code.yaml +15 -0
  23. package/framework/.claude/harnesses/codex.yaml +40 -0
  24. package/framework/.claude/harnesses/command-map.yaml +255 -0
  25. package/framework/.claude/harnesses/opencode.yaml +32 -0
  26. package/framework/.claude/harnesses/scenarios.yaml +45 -0
  27. package/framework/.claude/hooks/on-pr-event.sh +69 -0
  28. package/framework/.claude/hooks/on-session-start.sh +427 -0
  29. package/framework/.claude/hooks/on-stop.sh +104 -0
  30. package/framework/.claude/hooks/post-tool-use.sh +161 -0
  31. package/framework/.claude/hooks/pre-tool-use.sh +246 -0
  32. package/framework/.claude/schemas/agent-definition.schema.json +35 -0
  33. package/framework/.claude/schemas/benchmark-report.schema.json +148 -0
  34. package/framework/.claude/schemas/pm-to-ux-handoff.schema.yaml +128 -0
  35. package/framework/.claude/schemas/stage-definition.schema.json +233 -0
  36. package/framework/.claude/schemas/workflow-stage.schema.json +86 -0
  37. package/framework/.claude/schemas/workflow-template.schema.json +88 -0
  38. package/framework/.claude/settings.json +14 -0
  39. package/framework/.claude/skills/build-product-prototype/SKILL.md +35 -0
  40. package/framework/.claude/skills/build-product-prototype/prompts/main.md +78 -0
  41. package/framework/.claude/skills/clarify-requirements/SKILL.md +32 -0
  42. package/framework/.claude/skills/clarify-requirements/prompts/main.md +81 -0
  43. package/framework/.claude/skills/dependencies.yaml +139 -0
  44. package/framework/.claude/skills/draft-product-design/SKILL.md +40 -0
  45. package/framework/.claude/skills/draft-product-design/prompts/main.md +47 -0
  46. package/framework/.claude/skills/lc-analyze-frameworks/SKILL.md +43 -0
  47. package/framework/.claude/skills/lc-analyze-frameworks/prompts/main.md +95 -0
  48. package/framework/.claude/skills/lc-benchmark/SKILL.md +61 -0
  49. package/framework/.claude/skills/lc-build-codebase-knowledge/SKILL.md +38 -0
  50. package/framework/.claude/skills/lc-build-codebase-knowledge/prompts/build-codebase-knowledge.md +61 -0
  51. package/framework/.claude/skills/lc-collect-intelligence/SKILL.md +43 -0
  52. package/framework/.claude/skills/lc-collect-intelligence/prompts/main.md +72 -0
  53. package/framework/.claude/skills/lc-competitive-analysis/SKILL.md +43 -0
  54. package/framework/.claude/skills/lc-competitive-analysis/prompts/main.md +70 -0
  55. package/framework/.claude/skills/lc-explore-opensource/SKILL.md +36 -0
  56. package/framework/.claude/skills/lc-explore-opensource/prompts/explore-opensource.md +77 -0
  57. package/framework/.claude/skills/lc-first-principles/SKILL.md +43 -0
  58. package/framework/.claude/skills/lc-first-principles/prompts/main.md +73 -0
  59. package/framework/.claude/skills/lc-handoff/SKILL.md +34 -0
  60. package/framework/.claude/skills/lc-init-branch/SKILL.md +52 -0
  61. package/framework/.claude/skills/lc-market-research/SKILL.md +43 -0
  62. package/framework/.claude/skills/lc-market-research/prompts/main.md +79 -0
  63. package/framework/.claude/skills/lc-product-research/SKILL.md +43 -0
  64. package/framework/.claude/skills/lc-product-research/prompts/main.md +80 -0
  65. package/framework/.claude/skills/lc-research-report/SKILL.md +43 -0
  66. package/framework/.claude/skills/lc-research-report/prompts/main.md +90 -0
  67. package/framework/.claude/skills/lc-research-scope/SKILL.md +43 -0
  68. package/framework/.claude/skills/lc-research-scope/prompts/main.md +82 -0
  69. package/framework/.claude/skills/lc-setup/SKILL.md +68 -0
  70. package/framework/.claude/skills/lc-setup/prompts/main.md +88 -0
  71. package/framework/.claude/skills/lc-stage/SKILL.md +45 -0
  72. package/framework/.claude/skills/lc-stakeholder-research/SKILL.md +43 -0
  73. package/framework/.claude/skills/lc-stakeholder-research/prompts/main.md +65 -0
  74. package/framework/.claude/skills/lc-status/SKILL.md +25 -0
  75. package/framework/.claude/skills/lc-storytelling/SKILL.md +43 -0
  76. package/framework/.claude/skills/lc-storytelling/prompts/main.md +77 -0
  77. package/framework/.claude/skills/lc-wf/SKILL.md +54 -0
  78. package/framework/.claude/skills/lc-workflow/SKILL.md +48 -0
  79. package/framework/.claude/skills/lc-workflow/validators/validate.py +755 -0
  80. package/framework/.claude/skills/lc-workflow-router/SKILL.md +41 -0
  81. package/framework/.claude/skills/lc-workflow-router/prompts/intake-prompt.md +91 -0
  82. package/framework/.claude/skills/lc-workflow-router/prompts/router-prompt.md +44 -0
  83. package/framework/.claude/skills/plan-tdd-development/SKILL.md +31 -0
  84. package/framework/.claude/skills/plan-tdd-development/prompts/main.md +44 -0
  85. package/framework/.claude/skills/process-interview/SKILL.md +32 -0
  86. package/framework/.claude/skills/process-interview/prompts/main.md +47 -0
  87. package/framework/.claude/skills/propose-with-openspec/SKILL.md +37 -0
  88. package/framework/.claude/skills/propose-with-openspec/prompts/main.md +53 -0
  89. package/framework/.claude/skills/split-to-github/SKILL.md +32 -0
  90. package/framework/.claude/skills/split-to-github/prompts/main.md +42 -0
  91. package/framework/.claude/skills/sync-to-knowledge/SKILL.md +35 -0
  92. package/framework/.claude/skills/sync-to-knowledge/prompts/main.md +54 -0
  93. package/framework/.claude/skills/workflow-continue/SKILL.md +24 -0
  94. package/framework/.claude/skills/workflow-continue/prompts/main.md +25 -0
  95. package/framework/.claude/stages/build-codebase-knowledge.yaml +42 -0
  96. package/framework/.claude/stages/clarify.yaml +59 -0
  97. package/framework/.claude/stages/explore-opensource.yaml +40 -0
  98. package/framework/.claude/stages/implement.yaml +60 -0
  99. package/framework/.claude/stages/ingest.yaml +40 -0
  100. package/framework/.claude/stages/lc-analyze-frameworks.yaml +37 -0
  101. package/framework/.claude/stages/lc-collect-intelligence.yaml +37 -0
  102. package/framework/.claude/stages/lc-competitive-analysis.yaml +37 -0
  103. package/framework/.claude/stages/lc-first-principles.yaml +37 -0
  104. package/framework/.claude/stages/lc-market-research.yaml +37 -0
  105. package/framework/.claude/stages/lc-product-research.yaml +37 -0
  106. package/framework/.claude/stages/lc-research-report.yaml +38 -0
  107. package/framework/.claude/stages/lc-research-scope.yaml +38 -0
  108. package/framework/.claude/stages/lc-stakeholder-research.yaml +37 -0
  109. package/framework/.claude/stages/lc-storytelling.yaml +37 -0
  110. package/framework/.claude/stages/product-design-docs.yaml +66 -0
  111. package/framework/.claude/stages/product-prototype.yaml +53 -0
  112. package/framework/.claude/stages/propose.yaml +45 -0
  113. package/framework/.claude/stages/split.yaml +41 -0
  114. package/framework/.claude/stages/sync-knowledge.yaml +49 -0
  115. package/framework/.claude/stages/tdd-development-plan.yaml +43 -0
  116. package/framework/.claude/stages/workflow-router.yaml +45 -0
  117. package/framework/.claude/templates/solo-workflow-context.yaml +23 -0
  118. package/framework/.claude/workflows/README.md +93 -0
  119. package/framework/.claude/workflows/bug-fix.yaml +81 -0
  120. package/framework/.claude/workflows/design-spike.yaml +55 -0
  121. package/framework/.claude/workflows/existing-project-iteration.yaml +106 -0
  122. package/framework/.claude/workflows/interview-to-knowledge.yaml +189 -0
  123. package/framework/.claude/workflows/oss-first-design.yaml +100 -0
  124. package/framework/.claude/workflows/pm-research.yaml +115 -0
  125. package/framework/.claude-plugin/hooks.json +6 -0
  126. package/framework/.claude-plugin/marketplace.json +32 -0
  127. package/framework/.claude-plugin/plugin.json +57 -0
  128. package/framework/.version-bump.json +43 -0
  129. package/framework/CLAUDE.md +140 -0
  130. package/framework/CONTRIBUTING.md +219 -0
  131. package/framework/LICENSE +21 -0
  132. package/framework/README.en.md +139 -0
  133. package/framework/README.md +139 -0
  134. package/framework/RELEASE.md +334 -0
  135. package/framework/SKILL.md +24 -0
  136. package/framework/USAGE.md +356 -0
  137. package/framework/manifest.json +1214 -0
  138. package/framework/requirements.txt +4 -0
  139. package/framework/scripts/__init__.py +0 -0
  140. package/framework/scripts/build_codebase_knowledge.py +76 -0
  141. package/framework/scripts/bump_version.py +181 -0
  142. package/framework/scripts/check-dependency-drift.py +69 -0
  143. package/framework/scripts/check-harness-drift.sh +57 -0
  144. package/framework/scripts/check-main-merge-hygiene.py +128 -0
  145. package/framework/scripts/check-skill-dependencies.sh +124 -0
  146. package/framework/scripts/init-lincoln-branch.sh +300 -0
  147. package/framework/scripts/init-project.sh +184 -0
  148. package/framework/scripts/lc-benchmark-cli.py +66 -0
  149. package/framework/scripts/lincoln-audit.py +288 -0
  150. package/framework/scripts/lincoln-setup.py +651 -0
  151. package/framework/scripts/lincoln-status.py +448 -0
  152. package/framework/scripts/lincoln_benchmark.py +224 -0
  153. package/framework/scripts/lincoln_benchmark_eval.py +104 -0
  154. package/framework/scripts/lincoln_benchmark_metrics.py +818 -0
  155. package/framework/scripts/lincoln_benchmark_report.py +141 -0
  156. package/framework/scripts/lincoln_command_map.py +289 -0
  157. package/framework/scripts/lincoln_dependency_manager.py +335 -0
  158. package/framework/scripts/lincoln_documents.py +175 -0
  159. package/framework/scripts/lincoln_harness_adapter.py +319 -0
  160. package/framework/scripts/lincoln_index.py +255 -0
  161. package/framework/scripts/lincoln_paths.py +198 -0
  162. package/framework/scripts/lincoln_prd.py +227 -0
  163. package/framework/scripts/lincoln_role.py +46 -0
  164. package/framework/scripts/lincoln_scenario.py +59 -0
  165. package/framework/scripts/lincoln_session_start_metrics.py +71 -0
  166. package/framework/scripts/lincoln_skill_prompt.py +48 -0
  167. package/framework/scripts/lincoln_trace.py +335 -0
  168. package/framework/scripts/lincoln_workflow.py +199 -0
  169. package/framework/scripts/list-active-lincoln-branches.sh +87 -0
  170. package/framework/scripts/package-lincoln-plugin.py +340 -0
  171. package/framework/scripts/run-infrastructure-tests.py +84 -0
  172. package/framework/scripts/stage_loader.py +1071 -0
  173. package/framework/scripts/static-check.sh +356 -0
  174. package/framework/scripts/sync-external-agents.sh +210 -0
  175. package/framework/scripts/sync-framework-package.mjs +218 -0
  176. package/framework/scripts/task_tool_guard.py +213 -0
  177. package/framework/scripts/track-artifacts.py +140 -0
  178. package/framework/scripts/validate_stage.py +350 -0
  179. package/{README.md → framework/tools/lincoln/README.md} +4 -4
  180. package/framework/tools/lincoln/package-lock.json +3349 -0
  181. package/framework/tools/lincoln/package.json +48 -0
  182. package/framework/tools/lincoln/src/cli.tsx +60 -0
  183. package/framework/tools/lincoln/src/components/AudioMeter.tsx +34 -0
  184. package/framework/tools/lincoln/src/components/CancelledScreen.tsx +11 -0
  185. package/framework/tools/lincoln/src/components/DevicesMenu.tsx +79 -0
  186. package/framework/tools/lincoln/src/components/HintBar.tsx +25 -0
  187. package/framework/tools/lincoln/src/components/LogView.tsx +36 -0
  188. package/framework/tools/lincoln/src/components/ModelMenu.tsx +122 -0
  189. package/framework/tools/lincoln/src/components/ReadyScreen.tsx +54 -0
  190. package/framework/tools/lincoln/src/components/RecordingApp.tsx +211 -0
  191. package/framework/tools/lincoln/src/components/RecordingScreen.tsx +41 -0
  192. package/framework/tools/lincoln/src/components/SelectMenu.tsx +53 -0
  193. package/framework/tools/lincoln/src/components/StatusPanel.tsx +52 -0
  194. package/framework/tools/lincoln/src/components/StopConfirmation.tsx +32 -0
  195. package/framework/tools/lincoln/src/config/args.ts +35 -0
  196. package/framework/tools/lincoln/src/config/loadConfig.ts +81 -0
  197. package/framework/tools/lincoln/src/config/resolveConfig.ts +85 -0
  198. package/framework/tools/lincoln/src/devices/listDevices.ts +11 -0
  199. package/framework/tools/lincoln/src/devices/parseDevices.ts +25 -0
  200. package/framework/tools/lincoln/src/hooks/useKeyHandler.ts +63 -0
  201. package/framework/tools/lincoln/src/main.tsx +4 -0
  202. package/framework/tools/lincoln/src/models/models.ts +34 -0
  203. package/framework/tools/lincoln/src/models/runWarmup.ts +46 -0
  204. package/framework/tools/lincoln/src/models/warmupProgress.ts +30 -0
  205. package/framework/tools/lincoln/src/recording/spawnRecorder.ts +127 -0
  206. package/framework/tools/lincoln/src/recording/useRecorder.ts +146 -0
  207. package/framework/tools/lincoln/src/utils/amplitudeToMeter.ts +4 -0
  208. package/framework/tools/lincoln/src/utils/formatDuration.ts +6 -0
  209. package/framework/tools/lincoln/src/utils/sessionId.ts +25 -0
  210. package/framework/tools/lincoln/tests/cli.test.tsx +52 -0
  211. package/framework/tools/lincoln/tests/components/AudioMeter.test.tsx +31 -0
  212. package/framework/tools/lincoln/tests/components/CancelledScreen.test.tsx +12 -0
  213. package/framework/tools/lincoln/tests/components/ReadyScreen.test.tsx +68 -0
  214. package/framework/tools/lincoln/tests/components/RecordingApp.menus.test.tsx +158 -0
  215. package/framework/tools/lincoln/tests/components/RecordingApp.test.tsx +248 -0
  216. package/framework/tools/lincoln/tests/components/RecordingScreen.test.tsx +37 -0
  217. package/framework/tools/lincoln/tests/components/SelectMenu.test.tsx +59 -0
  218. package/framework/tools/lincoln/tests/components/StopConfirmation.test.tsx +38 -0
  219. package/framework/tools/lincoln/tests/config/args.test.ts +49 -0
  220. package/framework/tools/lincoln/tests/config/loadConfig.test.ts +59 -0
  221. package/framework/tools/lincoln/tests/config/resolveConfig.test.ts +72 -0
  222. package/framework/tools/lincoln/tests/devices/parseDevices.test.ts +21 -0
  223. package/framework/tools/lincoln/tests/hooks/useKeyHandler.enabled.test.tsx +44 -0
  224. package/framework/tools/lincoln/tests/hooks/useKeyHandler.test.tsx +130 -0
  225. package/framework/tools/lincoln/tests/models/models.test.ts +27 -0
  226. package/framework/tools/lincoln/tests/models/warmupProgress.test.ts +25 -0
  227. package/framework/tools/lincoln/tests/recording/spawnRecorder.test.ts +85 -0
  228. package/framework/tools/lincoln/tests/recording/useRecorder.test.tsx +172 -0
  229. package/framework/tools/lincoln/tests/utils/amplitudeToMeter.test.ts +22 -0
  230. package/framework/tools/lincoln/tests/utils/formatDuration.test.ts +11 -0
  231. package/framework/tools/lincoln/tests/utils/sessionId.test.ts +30 -0
  232. package/framework/tools/lincoln/tsconfig.json +22 -0
  233. package/framework/tools/lincoln/tsconfig.tests.json +10 -0
  234. package/framework/tools/lincoln/tsup.config.ts +17 -0
  235. package/framework/tools/lincoln/vitest.config.ts +20 -0
  236. package/framework/tools/lincoln-installer/package-lock.json +3611 -0
  237. package/framework/tools/lincoln-installer/package.json +53 -0
  238. package/framework/tools/lincoln-installer/src/cli.tsx +63 -0
  239. package/framework/tools/lincoln-installer/src/components/HarnessSelectScreen.tsx +43 -0
  240. package/framework/tools/lincoln-installer/src/components/InstallerApp.tsx +98 -0
  241. package/framework/tools/lincoln-installer/src/components/OptionsScreen.tsx +56 -0
  242. package/framework/tools/lincoln-installer/src/components/ProgressScreen.tsx +25 -0
  243. package/framework/tools/lincoln-installer/src/components/ResultScreen.tsx +40 -0
  244. package/framework/tools/lincoln-installer/src/components/SelectMenu.tsx +73 -0
  245. package/framework/tools/lincoln-installer/src/components/SummaryScreen.tsx +49 -0
  246. package/framework/tools/lincoln-installer/src/components/UpdateApp.tsx +135 -0
  247. package/framework/tools/lincoln-installer/src/components/WelcomeScreen.tsx +22 -0
  248. package/framework/tools/lincoln-installer/src/config/args.ts +76 -0
  249. package/framework/tools/lincoln-installer/src/config/resolveConfig.ts +33 -0
  250. package/framework/tools/lincoln-installer/src/config/updateArgs.ts +67 -0
  251. package/framework/tools/lincoln-installer/src/hooks/useSetup.ts +149 -0
  252. package/framework/tools/lincoln-installer/src/main.tsx +11 -0
  253. package/framework/tools/lincoln-installer/src/types.ts +19 -0
  254. package/framework/tools/lincoln-installer/src/update-main.tsx +11 -0
  255. package/framework/tools/lincoln-installer/src/update.tsx +90 -0
  256. package/framework/tools/lincoln-installer/src/utils/detectHarness.ts +51 -0
  257. package/framework/tools/lincoln-installer/src/utils/runSetup.ts +62 -0
  258. package/framework/tools/lincoln-installer/src/utils/updateRelease.ts +454 -0
  259. package/framework/tools/lincoln-installer/tests/components/InstallerApp.test.tsx +88 -0
  260. package/framework/tools/lincoln-installer/tests/components/SelectMenu.test.tsx +26 -0
  261. package/framework/tools/lincoln-installer/tests/components/UpdateApp.test.tsx +170 -0
  262. package/framework/tools/lincoln-installer/tests/components/screens.test.tsx +310 -0
  263. package/framework/tools/lincoln-installer/tests/config/args.test.ts +66 -0
  264. package/framework/tools/lincoln-installer/tests/config/resolveConfig.test.ts +75 -0
  265. package/framework/tools/lincoln-installer/tests/config/updateArgs.test.ts +52 -0
  266. package/framework/tools/lincoln-installer/tests/hooks/useSetup.test.tsx +213 -0
  267. package/framework/tools/lincoln-installer/tests/update.test.tsx +108 -0
  268. package/framework/tools/lincoln-installer/tests/utils/detectHarness.test.ts +40 -0
  269. package/framework/tools/lincoln-installer/tests/utils/runSetup.test.ts +45 -0
  270. package/framework/tools/lincoln-installer/tests/utils/updateRelease.test.ts +559 -0
  271. package/framework/tools/lincoln-installer/tsconfig.json +22 -0
  272. package/framework/tools/lincoln-installer/tsconfig.tests.json +8 -0
  273. package/framework/tools/lincoln-installer/tsup.config.ts +17 -0
  274. package/framework/tools/lincoln-installer/vitest.config.ts +20 -0
  275. package/framework/tools/lincoln-record/Cargo.lock +1896 -0
  276. package/framework/tools/lincoln-record/Cargo.toml +24 -0
  277. package/framework/tools/lincoln-record/diarize/diarize.py +117 -0
  278. package/framework/tools/lincoln-record/diarize/merge.py +55 -0
  279. package/framework/tools/lincoln-record/src/audio/capture/microphone.rs +194 -0
  280. package/framework/tools/lincoln-record/src/audio/capture/mod.rs +1 -0
  281. package/framework/tools/lincoln-record/src/audio/devices.rs +20 -0
  282. package/framework/tools/lincoln-record/src/audio/mod.rs +5 -0
  283. package/framework/tools/lincoln-record/src/audio/resampler.rs +44 -0
  284. package/framework/tools/lincoln-record/src/audio/saver.rs +87 -0
  285. package/framework/tools/lincoln-record/src/audio/source.rs +104 -0
  286. package/framework/tools/lincoln-record/src/cli.rs +96 -0
  287. package/framework/tools/lincoln-record/src/config.rs +135 -0
  288. package/framework/tools/lincoln-record/src/lib.rs +6 -0
  289. package/framework/tools/lincoln-record/src/main.rs +242 -0
  290. package/framework/tools/lincoln-record/src/model/mod.rs +248 -0
  291. package/framework/tools/lincoln-record/src/output/metadata.rs +30 -0
  292. package/framework/tools/lincoln-record/src/output/mod.rs +2 -0
  293. package/framework/tools/lincoln-record/src/output/transcript.rs +42 -0
  294. package/framework/tools/lincoln-record/src/transcription/diarization.rs +64 -0
  295. package/framework/tools/lincoln-record/src/transcription/mod.rs +3 -0
  296. package/framework/tools/lincoln-record/src/transcription/provider.rs +43 -0
  297. package/framework/tools/lincoln-record/src/transcription/whisper.rs +176 -0
  298. package/framework/tools/lincoln-record/tests/cli_args.rs +103 -0
  299. package/framework/tools/lincoln-record/tests/config_test.rs +70 -0
  300. package/framework/tools/lincoln-record/tests/devices_test.rs +23 -0
  301. package/framework/tools/lincoln-record/tests/diarization_tests.rs +101 -0
  302. package/framework/tools/lincoln-record/tests/integration_tests.rs +44 -0
  303. package/framework/tools/lincoln-record/tests/model_test.rs +68 -0
  304. package/framework/tools/lincoln-record/tests/transcription_tests.rs +73 -0
  305. package/package.json +19 -13
@@ -0,0 +1,1071 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Lincoln stage loader / dispatcher.
4
+
5
+ Loads stage-specific context from `.claude/stages/<stage>.yaml`, runs
6
+ entry/exit validators, and manages the per-process workflow state file at
7
+ `<process_slug>/workflow-stage.yaml`.
8
+
9
+ Usage:
10
+ python scripts/stage_loader.py --stage clarify --action load
11
+ python scripts/stage_loader.py --stage product-design-docs --action validate-entry
12
+ python scripts/stage_loader.py --stage product-design-docs --action validate-exit
13
+ python scripts/stage_loader.py --stage clarify --action transition-next
14
+ python scripts/stage_loader.py --action recover
15
+ python scripts/stage_loader.py --stage clarify --action approve-gate
16
+ python scripts/stage_loader.py --stage clarify --action append-node --node-id clarify --status in_progress
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import glob
23
+ import json
24
+ import os
25
+ import re
26
+ import subprocess
27
+ import sys
28
+ import tempfile
29
+ from datetime import datetime, timezone
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
34
+ if str(PROJECT_ROOT) not in sys.path:
35
+ sys.path.insert(0, str(PROJECT_ROOT))
36
+
37
+ import yaml
38
+
39
+ from scripts import lincoln_trace
40
+ from scripts.lincoln_documents import write_documents_index
41
+ from scripts.lincoln_index import write_package_index
42
+ from scripts.lincoln_paths import (
43
+ LEGACY_STATE_PATH,
44
+ ROOT_STATE_PATH,
45
+ get_process_slug,
46
+ interpolate_process_path,
47
+ process_package_root,
48
+ resolve_state_path as resolve_lincoln_state_path,
49
+ )
50
+
51
+ WORKFLOW_PATH = PROJECT_ROOT / ".claude" / "workflows" / "interview-to-knowledge.yaml"
52
+ WORKFLOW_TEMPLATE_DIR = PROJECT_ROOT / ".claude" / "workflows"
53
+ DEFAULT_WORKFLOW_PATH = WORKFLOW_PATH
54
+
55
+ STATE_PATH = ROOT_STATE_PATH
56
+ STAGES_DIR = PROJECT_ROOT / ".claude" / "stages"
57
+ VALIDATOR_PATH = PROJECT_ROOT / "scripts" / "validate_stage.py"
58
+
59
+ REQUIRED_STATE_KEYS = {
60
+ "schema_version",
61
+ "workflow",
62
+ "current_run",
63
+ "nodes",
64
+ "recovery",
65
+ }
66
+
67
+ LEGACY_REQUIRED_STATE_KEYS = {
68
+ "schema_version",
69
+ "workflow",
70
+ "current_run",
71
+ "stages",
72
+ "variables",
73
+ "recovery",
74
+ }
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Helpers
79
+ # ---------------------------------------------------------------------------
80
+
81
+
82
+ def now_iso() -> str:
83
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
84
+
85
+
86
+ def load_yaml(path: Path) -> Any:
87
+ if not path.exists():
88
+ raise FileNotFoundError(path)
89
+ return yaml.safe_load(path.read_text(encoding="utf-8"))
90
+
91
+
92
+ def save_yaml(path: Path, data: Any) -> None:
93
+ """Write YAML atomically to avoid corrupting the state file."""
94
+ path.parent.mkdir(parents=True, exist_ok=True)
95
+ tmp_fd, tmp_path = tempfile.mkstemp(dir=path.parent, suffix=".yaml.tmp")
96
+ try:
97
+ with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
98
+ yaml.dump(data, f, allow_unicode=True, sort_keys=False, width=120)
99
+ os.replace(tmp_path, path)
100
+ except Exception:
101
+ if os.path.exists(tmp_path):
102
+ os.unlink(tmp_path)
103
+ raise
104
+
105
+
106
+ def resolve_workflow_path(template_name: str | None = None) -> Path:
107
+ if not template_name:
108
+ return DEFAULT_WORKFLOW_PATH
109
+ template_path = WORKFLOW_TEMPLATE_DIR / f"{template_name}.yaml"
110
+ if template_path.exists():
111
+ return template_path
112
+ raise FileNotFoundError(f"Workflow template not found: {template_path}")
113
+
114
+
115
+ def load_workflow(template_name: str | None = None) -> dict[str, Any]:
116
+ path = resolve_workflow_path(template_name)
117
+ data = load_yaml(path)
118
+ return data.get("workflow", data)
119
+
120
+
121
+ def list_workflow_templates() -> list[str]:
122
+ if not WORKFLOW_TEMPLATE_DIR.exists():
123
+ return []
124
+ return sorted(p.stem for p in WORKFLOW_TEMPLATE_DIR.glob("*.yaml"))
125
+
126
+
127
+ def _is_legacy_state(state: dict[str, Any]) -> bool:
128
+ """Detect whether a state file uses the legacy schema (has 'stages' key)."""
129
+ return "stages" in state and "nodes" not in state
130
+
131
+
132
+ def load_state(path: Path | None = None) -> dict[str, Any]:
133
+ path = resolve_state_path(path)
134
+ if not path.exists():
135
+ raise FileNotFoundError(path)
136
+ state = load_yaml(path)
137
+ if not isinstance(state, dict):
138
+ raise ValueError(f"State file {path} must contain a YAML mapping")
139
+
140
+ if _is_legacy_state(state):
141
+ missing = LEGACY_REQUIRED_STATE_KEYS - set(state.keys())
142
+ if missing:
143
+ raise ValueError(f"Legacy state file missing keys: {', '.join(missing)}")
144
+ else:
145
+ missing = REQUIRED_STATE_KEYS - set(state.keys())
146
+ if missing:
147
+ raise ValueError(f"State file missing keys: {', '.join(missing)}")
148
+ return state
149
+
150
+
151
+ def save_state(state: dict[str, Any], path: Path | None = None) -> None:
152
+ path = resolve_state_path(path)
153
+ state["current_run"]["last_updated_at"] = now_iso()
154
+ save_yaml(path, state)
155
+ write_package_index(state, path)
156
+ write_documents_index(state, path)
157
+
158
+
159
+ def resolve_state_path(path: Path | None = None) -> Path:
160
+ """Return the actual state file path to use, preferring new over legacy."""
161
+ return resolve_lincoln_state_path(path, PROJECT_ROOT)
162
+
163
+
164
+ def find_stage(workflow: dict[str, Any], stage_id: str) -> dict[str, Any]:
165
+ for step in workflow.get("steps", []):
166
+ if step.get("id") == stage_id:
167
+ return step
168
+ raise ValueError(f"Stage '{stage_id}' not found in workflow")
169
+
170
+
171
+ def compute_next_stage(workflow: dict[str, Any], stage_id: str) -> str | None:
172
+ steps = workflow.get("steps", [])
173
+ ids = [s["id"] for s in steps]
174
+ try:
175
+ idx = ids.index(stage_id)
176
+ except ValueError:
177
+ raise ValueError(f"Stage '{stage_id}' not found in workflow")
178
+ if idx + 1 < len(ids):
179
+ return ids[idx + 1]
180
+ return None
181
+
182
+
183
+ def interpolate(value: str, variables: dict[str, Any]) -> str:
184
+ """Replace {var} placeholders with variable values."""
185
+
186
+ def replacer(match: re.Match) -> str:
187
+ key = match.group(1)
188
+ if key in variables and variables[key] is not None:
189
+ return str(variables[key])
190
+ return match.group(0)
191
+
192
+ return re.sub(r"\{(\w+)\}", replacer, value)
193
+
194
+
195
+ def interpolate_artifact(value: str, state: dict[str, Any], state_file: Path | None = None) -> str:
196
+ value = interpolate(value, get_variables(state))
197
+ return interpolate_process_path(value, state, resolve_state_path(state_file))
198
+
199
+
200
+ def get_variables(state: dict[str, Any]) -> dict[str, Any]:
201
+ """Extract variables from state, handling both new and legacy schemas."""
202
+ if _is_legacy_state(state):
203
+ return state.get("variables", {})
204
+ return state.get("current_run", {}).get("variables", {})
205
+
206
+
207
+ def get_stage_state(state: dict[str, Any], stage_id: str) -> dict[str, Any]:
208
+ """Get stage state from legacy schema."""
209
+ return state.get("stages", {}).get(stage_id, {})
210
+
211
+
212
+ def get_nodes(state: dict[str, Any]) -> list[dict[str, Any]]:
213
+ """Get nodes array from new schema."""
214
+ return state.get("nodes", [])
215
+
216
+
217
+ def get_latest_node_for_stage(state: dict[str, Any], stage_id: str) -> dict[str, Any] | None:
218
+ """Return the latest node for a given stage, or None."""
219
+ nodes = get_nodes(state)
220
+ matching = [n for n in nodes if n.get("stage_id") == stage_id]
221
+ if not matching:
222
+ return None
223
+ return matching[-1]
224
+
225
+
226
+ def load_stage_yaml(stage_id: str) -> dict[str, Any]:
227
+ """Load the single-YAML stage definition."""
228
+ path = STAGES_DIR / f"{stage_id}.yaml"
229
+ if not path.exists():
230
+ raise FileNotFoundError(f"Stage YAML not found: {path}")
231
+ return load_yaml(path)
232
+
233
+
234
+ def list_stage_ids() -> list[str]:
235
+ """Return all stage IDs discovered from .claude/stages/*.yaml."""
236
+ if not STAGES_DIR.exists():
237
+ return []
238
+ return sorted(p.stem for p in STAGES_DIR.glob("*.yaml") if p.stem != "stage-manifest")
239
+
240
+
241
+ def load_skill_routing() -> dict[str, Any]:
242
+ """Build skill routing from stage YAMLs for backward compatibility."""
243
+ routing: dict[str, Any] = {"routing": {}}
244
+ for stage_id in list_stage_ids():
245
+ try:
246
+ stage = load_stage_yaml(stage_id)
247
+ skills = stage.get("skills", {})
248
+ routing["routing"][stage_id] = {
249
+ "required": skills.get("required", []),
250
+ "optional": skills.get("optional", []),
251
+ "human_gate": stage.get("human_gate", False),
252
+ }
253
+ except Exception:
254
+ continue
255
+ return routing
256
+
257
+
258
+ def get_stage_skills(_routing_data: dict[str, Any] | None, stage_id: str) -> dict[str, list[str]]:
259
+ """Extract required/optional skills for a stage from stage YAML."""
260
+ try:
261
+ stage = load_stage_yaml(stage_id)
262
+ skills = stage.get("skills", {})
263
+ return {
264
+ "required": skills.get("required", []),
265
+ "optional": skills.get("optional", []),
266
+ }
267
+ except Exception:
268
+ return {"required": [], "optional": []}
269
+
270
+
271
+ def run_validator(
272
+ phase: str,
273
+ check_name: str,
274
+ args: list[str],
275
+ state_file: Path | None = None,
276
+ project_root: Path | None = None,
277
+ ) -> tuple[int, str, str]:
278
+ """Run a validator check via validate_stage.py subprocess."""
279
+ if not VALIDATOR_PATH.exists():
280
+ raise FileNotFoundError(f"Validator not found: {VALIDATOR_PATH}")
281
+
282
+ args_str = ",".join(args)
283
+ cmd = [
284
+ sys.executable,
285
+ str(VALIDATOR_PATH),
286
+ "--phase",
287
+ phase,
288
+ "--check",
289
+ check_name,
290
+ ]
291
+ if args_str:
292
+ cmd.extend(["--args", args_str])
293
+ if state_file is not None:
294
+ cmd.extend(["--state-file", str(state_file)])
295
+
296
+ env = os.environ.copy()
297
+ if project_root is not None:
298
+ env["LINCOLN_PROJECT_ROOT"] = str(project_root)
299
+
300
+ result = subprocess.run(cmd, capture_output=True, text=True, env=env)
301
+ return result.returncode, result.stdout, result.stderr
302
+
303
+
304
+ # ---------------------------------------------------------------------------
305
+ # Context resolution
306
+ # ---------------------------------------------------------------------------
307
+
308
+
309
+ def resolve_stage_context(
310
+ stage_id: str,
311
+ state: dict[str, Any],
312
+ state_file: Path | None = None,
313
+ ) -> dict[str, Any]:
314
+ """Return a deterministic context dict for the current stage.
315
+
316
+ Merges:
317
+ 1. stage YAML (identity, agents, skills, artifacts, context, gates)
318
+ 2. workflow template step (for ordering and name override)
319
+ 3. interpolated variables from workflow-stage.yaml
320
+ """
321
+ stage = load_stage_yaml(stage_id)
322
+ template_name = state.get("workflow", {}).get("template")
323
+ workflow = load_workflow(template_name)
324
+ workflow_step = find_stage(workflow, stage_id)
325
+
326
+ variables = get_variables(state)
327
+ variables.setdefault("process_slug", get_process_slug(state, resolve_state_path(state_file)))
328
+
329
+ context = stage.get("context", {})
330
+ interpolated_context = {
331
+ key: interpolate(value, variables)
332
+ for key, value in context.items()
333
+ if isinstance(value, str)
334
+ }
335
+
336
+ artifacts = stage.get("artifacts", {})
337
+ required_artifacts = [
338
+ interpolate_artifact(str(art), state, state_file)
339
+ for art in artifacts.get("required", [])
340
+ ]
341
+ optional_artifacts = [
342
+ interpolate_artifact(str(art), state, state_file)
343
+ for art in artifacts.get("optional", [])
344
+ ]
345
+
346
+ agent = stage.get("agent", {})
347
+ skills = stage.get("skills", {})
348
+
349
+ return {
350
+ "stage_id": stage_id,
351
+ "stage_name": workflow_step.get("name", stage.get("name", stage_id)),
352
+ "description": stage.get("description", ""),
353
+ "human_gate": stage.get("human_gate", False),
354
+ "next_stage": compute_next_stage(workflow, stage_id),
355
+ "primary_agent": agent.get("primary"),
356
+ "review_agents": agent.get("reviewers", []),
357
+ "handoff_to": agent.get("handoff_to"),
358
+ "required_skills": skills.get("required", []),
359
+ "optional_skills": skills.get("optional", []),
360
+ "required_artifacts": required_artifacts,
361
+ "optional_artifacts": optional_artifacts,
362
+ "context": interpolated_context,
363
+ "context_path": str((STAGES_DIR / f"{stage_id}.yaml").relative_to(PROJECT_ROOT)),
364
+ "workflow_template": template_name,
365
+ }
366
+
367
+
368
+ # ---------------------------------------------------------------------------
369
+ # Actions
370
+ # ---------------------------------------------------------------------------
371
+
372
+
373
+ def action_load(stage_id: str, state: dict[str, Any], state_file: Path | None = None) -> dict[str, Any]:
374
+ context = resolve_stage_context(stage_id, state, state_file)
375
+
376
+ if _is_legacy_state(state):
377
+ stage_status = state.get("stages", {}).get(stage_id, {}).get("status")
378
+ else:
379
+ latest_node = get_latest_node_for_stage(state, stage_id)
380
+ stage_status = latest_node.get("status") if latest_node else state.get("current_run", {}).get("status")
381
+
382
+ return {
383
+ "stage_id": context["stage_id"],
384
+ "stage_name": context["stage_name"],
385
+ "stage_status": stage_status,
386
+ "human_gate": context["human_gate"],
387
+ "next_stage": context["next_stage"],
388
+ "context": json.dumps(context["context"], ensure_ascii=False),
389
+ "context_paths": [context["context_path"]],
390
+ "required_skills": context["required_skills"],
391
+ "optional_skills": context["optional_skills"],
392
+ }
393
+
394
+
395
+ def _check_artifacts_present(stage_id: str, state: dict[str, Any], state_file: Path | None = None) -> bool:
396
+ """Check that all required artifacts for the stage exist and are non-empty."""
397
+ stage = load_stage_yaml(stage_id)
398
+ artifacts = stage.get("artifacts", {})
399
+ required = artifacts.get("required", [])
400
+ if not required:
401
+ return True
402
+
403
+ all_present = True
404
+ for art in required:
405
+ path = interpolate_artifact(str(art), state, state_file)
406
+ if "*" in path or "?" in path:
407
+ matches = list(PROJECT_ROOT.glob(path))
408
+ if not matches or all(m.stat().st_size == 0 for m in matches):
409
+ print(f"FAIL: artifact missing or empty: {path}", file=sys.stderr)
410
+ all_present = False
411
+ else:
412
+ for match in matches:
413
+ print(f"PASS: artifact present: {match.relative_to(PROJECT_ROOT)}")
414
+ else:
415
+ target = PROJECT_ROOT / path
416
+ if not target.exists() or target.stat().st_size == 0:
417
+ print(f"FAIL: artifact missing or empty: {path}", file=sys.stderr)
418
+ all_present = False
419
+ else:
420
+ print(f"PASS: artifact present: {path}")
421
+ return all_present
422
+
423
+
424
+ def _check_human_approved(stage_id: str, state: dict[str, Any]) -> bool:
425
+ """Check that the latest node for the stage is marked as approved."""
426
+ latest_node = get_latest_node_for_stage(state, stage_id)
427
+ if latest_node is None:
428
+ print(f"FAIL: no node found for stage '{stage_id}'", file=sys.stderr)
429
+ return False
430
+ if bool(latest_node.get("gate_passed")) and latest_node.get("approved_by"):
431
+ print(f"PASS: human approved for stage '{stage_id}'")
432
+ return True
433
+ print(f"FAIL: stage '{stage_id}' not approved", file=sys.stderr)
434
+ return False
435
+
436
+
437
+ def _check_previous_stage_completed(stage_id: str, prev_stage_id: str, state: dict[str, Any]) -> bool:
438
+ """Check that the prerequisite stage has a completed node."""
439
+ prev_node = get_latest_node_for_stage(state, prev_stage_id)
440
+ if prev_node and prev_node.get("status") == "completed":
441
+ print(f"PASS: previous stage '{prev_stage_id}' completed")
442
+ return True
443
+ print(f"FAIL: previous stage '{prev_stage_id}' not completed", file=sys.stderr)
444
+ return False
445
+
446
+
447
+ def _run_gate_check(
448
+ phase: str,
449
+ check: dict[str, Any],
450
+ stage_id: str,
451
+ state: dict[str, Any],
452
+ state_file: Path | None = None,
453
+ ) -> bool:
454
+ """Run a single gate check. Returns True if passed."""
455
+ check_name = check.get("check")
456
+ raw_args = check.get("args", [])
457
+ variables = get_variables(state)
458
+ variables.setdefault("process_slug", get_process_slug(state, resolve_state_path(state_file)))
459
+ args = [
460
+ interpolate_process_path(interpolate(str(a), variables), state, resolve_state_path(state_file))
461
+ for a in raw_args
462
+ ]
463
+
464
+ if check_name == "artifacts_present":
465
+ return _check_artifacts_present(stage_id, state, state_file)
466
+
467
+ if check_name in ("human_approved", "gate_review_passed"):
468
+ return _check_human_approved(stage_id, state)
469
+
470
+ if check_name == "previous_stage_completed":
471
+ if not args:
472
+ print(f"FAIL: previous_stage_completed requires an argument", file=sys.stderr)
473
+ return False
474
+ return _check_previous_stage_completed(stage_id, args[0], state)
475
+
476
+ # Structural checks delegated to validate_stage.py
477
+ exit_code, stdout, stderr = run_validator(phase, check_name, args, state_file)
478
+ if exit_code == 0:
479
+ print(f"PASS: {phase} check '{check_name}'")
480
+ return True
481
+ print(f"FAIL: {phase} check '{check_name}'", file=sys.stderr)
482
+ if stdout:
483
+ print(stdout, file=sys.stderr)
484
+ if stderr:
485
+ print(stderr, file=sys.stderr)
486
+ return False
487
+
488
+
489
+ def action_validate(
490
+ stage_id: str,
491
+ state: dict[str, Any],
492
+ phase: str,
493
+ state_file: Path | None = None,
494
+ ) -> int:
495
+ """Run entry/exit checks from the stage YAML gates."""
496
+ stage = load_stage_yaml(stage_id)
497
+ checks = stage.get("gates", {}).get(phase, [])
498
+
499
+ if _is_legacy_state(state):
500
+ stage_state = state.setdefault("stages", {}).setdefault(stage_id, {})
501
+ stage_state["started_at"] = stage_state.get("started_at") or now_iso()
502
+ if phase == "entry":
503
+ stage_state["status"] = "entry_validating"
504
+ save_state(state, state_file)
505
+
506
+ if "validator_history" not in stage_state:
507
+ stage_state["validator_history"] = []
508
+ else:
509
+ if phase == "entry":
510
+ state["current_run"]["status"] = "entry_validating"
511
+ save_state(state, state_file)
512
+
513
+ all_passed = True
514
+ for check in checks:
515
+ passed = _run_gate_check(phase, check, stage_id, state, state_file)
516
+ if _is_legacy_state(state):
517
+ stage_state["validator_history"].append(
518
+ {
519
+ "phase": phase,
520
+ "check": check.get("check"),
521
+ "exit_code": 0 if passed else 1,
522
+ "run_at": now_iso(),
523
+ }
524
+ )
525
+ if not passed:
526
+ all_passed = False
527
+
528
+ if not all_passed:
529
+ if _is_legacy_state(state):
530
+ stage_state["status"] = "validation_failed"
531
+ stage_state["retry_count"] = stage_state.get("retry_count", 0) + 1
532
+ else:
533
+ state["current_run"]["status"] = "validation_failed"
534
+ save_state(state, state_file)
535
+ return 1
536
+
537
+ now = now_iso()
538
+ if _is_legacy_state(state):
539
+ if phase == "entry":
540
+ stage_state["status"] = "in_progress"
541
+ stage_state["entry_checks_passed"] = True
542
+ stage_state["entry_checks_run_at"] = now
543
+ else:
544
+ stage_state["exit_checks_passed"] = True
545
+ stage_state["exit_checks_run_at"] = now
546
+ stage_state["error_message"] = None
547
+ else:
548
+ if phase == "entry":
549
+ state["current_run"]["status"] = "in_progress"
550
+ save_state(state, state_file)
551
+
552
+ print(f"PASS: all {phase} checks for '{stage_id}'")
553
+ return 0
554
+
555
+
556
+ def action_record_artifacts(
557
+ stage_id: str,
558
+ state: dict[str, Any],
559
+ state_file: Path | None = None,
560
+ ) -> list[str]:
561
+ """Discover existing artifact paths declared by the stage YAML and record them."""
562
+ stage = load_stage_yaml(stage_id)
563
+ variables = get_variables(state)
564
+ variables.setdefault("process_slug", get_process_slug(state, resolve_state_path(state_file)))
565
+
566
+ recorded: list[str] = []
567
+ artifacts = stage.get("artifacts", {})
568
+ for art in artifacts.get("required", []) + artifacts.get("optional", []):
569
+ path = interpolate_artifact(str(art), state, state_file)
570
+ if "*" in path or "?" in path:
571
+ for match in PROJECT_ROOT.glob(path):
572
+ if match.exists():
573
+ recorded.append(str(match.relative_to(PROJECT_ROOT)))
574
+ else:
575
+ target = PROJECT_ROOT / path
576
+ if target.exists():
577
+ recorded.append(str(path))
578
+
579
+ latest_node = get_latest_node_for_stage(state, stage_id)
580
+ if latest_node is None:
581
+ latest_node = {
582
+ "stage_id": stage_id,
583
+ "node_id": f"{stage_id}-{now_iso()}",
584
+ "status": "in_progress",
585
+ "started_at": now_iso(),
586
+ "completed_at": None,
587
+ "gate_passed": False,
588
+ "approved_by": None,
589
+ "artifacts": [],
590
+ "handoff_file": None,
591
+ }
592
+ state.setdefault("nodes", []).append(latest_node)
593
+
594
+ existing = set(latest_node.get("artifacts", []))
595
+ existing.update(recorded)
596
+ latest_node["artifacts"] = sorted(existing)
597
+
598
+ save_state(state, state_file)
599
+ print(f"Recorded {len(recorded)} artifacts for stage '{stage_id}'")
600
+ for art in recorded:
601
+ print(f" - {art}")
602
+ return recorded
603
+
604
+
605
+ def action_approve_gate(
606
+ stage_id: str,
607
+ state: dict[str, Any],
608
+ state_file: Path | None = None,
609
+ approved_by: str = "human-pm",
610
+ ) -> int:
611
+ """Mark the current/latest node for the stage as gate_passed: true."""
612
+ if _is_legacy_state(state):
613
+ stage_state = state.setdefault("stages", {}).setdefault(stage_id, {})
614
+ stage_state["human_gate_passed"] = True
615
+ stage_state["human_gate_passed_at"] = now_iso()
616
+ save_state(state, state_file)
617
+ print(f"PASS: gate approved for stage '{stage_id}' (legacy mode)")
618
+ return 0
619
+
620
+ latest_node = get_latest_node_for_stage(state, stage_id)
621
+ if latest_node is None:
622
+ new_node = {
623
+ "stage_id": stage_id,
624
+ "node_id": f"{stage_id}-{now_iso()}",
625
+ "status": "completed",
626
+ "gate_passed": True,
627
+ "approved_by": approved_by,
628
+ "started_at": now_iso(),
629
+ "completed_at": now_iso(),
630
+ "artifacts": [],
631
+ "handoff_file": None,
632
+ }
633
+ state.setdefault("nodes", []).append(new_node)
634
+ else:
635
+ latest_node["gate_passed"] = True
636
+ latest_node["approved_by"] = approved_by
637
+ latest_node["completed_at"] = now_iso()
638
+
639
+ save_state(state, state_file)
640
+ print(f"PASS: gate approved for stage '{stage_id}' by '{approved_by}'")
641
+ return 0
642
+
643
+
644
+ def action_append_node(
645
+ stage_id: str,
646
+ state: dict[str, Any],
647
+ node_id: str,
648
+ status: str,
649
+ state_file: Path | None = None,
650
+ handoff_file: str | None = None,
651
+ artifacts: list[str] | None = None,
652
+ ) -> int:
653
+ """Append a node record to the nodes array."""
654
+ if _is_legacy_state(state):
655
+ stage_state = state.setdefault("stages", {}).setdefault(stage_id, {})
656
+ stage_state["status"] = status
657
+ if status == "completed":
658
+ stage_state["completed_at"] = now_iso()
659
+ if "started_at" not in stage_state or stage_state["started_at"] is None:
660
+ stage_state["started_at"] = now_iso()
661
+ if artifacts:
662
+ existing = set(stage_state.get("artifacts_produced", []))
663
+ existing.update(artifacts)
664
+ stage_state["artifacts_produced"] = list(existing)
665
+ save_state(state, state_file)
666
+ print(f"Legacy node appended: stage '{stage_id}' -> status '{status}'")
667
+ return 0
668
+
669
+ node = {
670
+ "stage_id": stage_id,
671
+ "node_id": node_id,
672
+ "status": status,
673
+ "started_at": now_iso(),
674
+ "completed_at": None,
675
+ "gate_passed": False,
676
+ "approved_by": None,
677
+ "artifacts": artifacts or [],
678
+ "handoff_file": handoff_file,
679
+ }
680
+ state.setdefault("nodes", []).append(node)
681
+
682
+ state["current_run"]["current_stage"] = stage_id
683
+ state["current_run"]["status"] = status
684
+
685
+ save_state(state, state_file)
686
+ print(f"Node appended: '{node_id}' for stage '{stage_id}' with status '{status}'")
687
+ return 0
688
+
689
+
690
+ def action_transition_next(stage_id: str, state: dict[str, Any], state_file: Path | None = None) -> str | None:
691
+ template_name = state.get("workflow", {}).get("template")
692
+ workflow = load_workflow(template_name)
693
+ next_stage_id = compute_next_stage(workflow, stage_id)
694
+
695
+ if _is_legacy_state(state):
696
+ stage_state = state.setdefault("stages", {}).setdefault(stage_id, {})
697
+ stage_state["status"] = "completed"
698
+ stage_state["completed_at"] = now_iso()
699
+
700
+ started_at = stage_state.get("started_at")
701
+ completed_at = stage_state.get("completed_at")
702
+ if started_at and completed_at:
703
+ try:
704
+ start_dt = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
705
+ end_dt = datetime.fromisoformat(completed_at.replace("Z", "+00:00"))
706
+ stage_state["duration_seconds"] = int((end_dt - start_dt).total_seconds())
707
+ except (ValueError, TypeError):
708
+ stage_state["duration_seconds"] = None
709
+ else:
710
+ stage_state["duration_seconds"] = None
711
+
712
+ if next_stage_id:
713
+ next_state = state.setdefault("stages", {}).setdefault(next_stage_id, {})
714
+ next_state.update(
715
+ {
716
+ "status": "not_started",
717
+ "started_at": None,
718
+ "completed_at": None,
719
+ "entry_checks_passed": None,
720
+ "entry_checks_run_at": None,
721
+ "exit_checks_passed": None,
722
+ "exit_checks_run_at": None,
723
+ "human_gate_passed": None,
724
+ "human_gate_passed_at": None,
725
+ "artifacts_produced": [],
726
+ "error_message": None,
727
+ "retry_count": 0,
728
+ }
729
+ )
730
+ state["current_run"]["current_stage"] = next_stage_id
731
+ state["current_run"]["previous_stage"] = stage_id
732
+ else:
733
+ state["current_run"]["current_stage"] = None
734
+ state["current_run"]["previous_stage"] = stage_id
735
+ state["current_run"]["status"] = "completed"
736
+ else:
737
+ now = now_iso()
738
+ recorded_artifacts = action_record_artifacts(stage_id, state, state_file)
739
+ completed_node = {
740
+ "stage_id": stage_id,
741
+ "node_id": f"{stage_id}-completed",
742
+ "status": "completed",
743
+ "started_at": now,
744
+ "completed_at": now,
745
+ "gate_passed": True,
746
+ "approved_by": "system",
747
+ "artifacts": recorded_artifacts,
748
+ "handoff_file": None,
749
+ }
750
+ state.setdefault("nodes", []).append(completed_node)
751
+
752
+ if next_stage_id:
753
+ state["current_run"]["current_stage"] = next_stage_id
754
+ state["current_run"]["previous_stage"] = stage_id
755
+ state["current_run"]["status"] = "not_started"
756
+ else:
757
+ state["current_run"]["current_stage"] = None
758
+ state["current_run"]["previous_stage"] = stage_id
759
+ state["current_run"]["status"] = "completed"
760
+
761
+ save_state(state, state_file)
762
+ print(f"Transitioned from '{stage_id}' to '{next_stage_id}'")
763
+ return next_stage_id
764
+
765
+
766
+ def action_recover(state: dict[str, Any], state_file: Path | None = None) -> dict[str, Any]:
767
+ template_name = state.get("workflow", {}).get("template")
768
+ workflow = load_workflow(template_name)
769
+ step_ids = [s["id"] for s in workflow.get("steps", [])]
770
+
771
+ if _is_legacy_state(state):
772
+ stages = state.get("stages", {})
773
+ last_completed = None
774
+ for sid in step_ids:
775
+ if stages.get(sid, {}).get("status") == "completed":
776
+ last_completed = sid
777
+
778
+ resume_point = state["current_run"].get("current_stage") or last_completed
779
+ if resume_point and stages.get(resume_point, {}).get("status") == "completed":
780
+ resume_point = compute_next_stage(workflow, resume_point)
781
+
782
+ state["recovery"]["last_validated_checkpoint"] = last_completed
783
+ state["recovery"]["can_resume_from"] = resume_point
784
+ save_state(state, state_file)
785
+
786
+ return {
787
+ "last_completed": last_completed,
788
+ "can_resume_from": resume_point,
789
+ "current_stage": state["current_run"].get("current_stage"),
790
+ "current_status": state["current_run"].get("status"),
791
+ }
792
+
793
+ nodes = get_nodes(state)
794
+ last_completed = None
795
+ for node in reversed(nodes):
796
+ if node.get("status") == "completed" and node.get("stage_id") in step_ids:
797
+ last_completed = node.get("stage_id")
798
+ break
799
+
800
+ current_stage = state["current_run"].get("current_stage")
801
+ resume_point = current_stage or last_completed
802
+ if resume_point:
803
+ latest = get_latest_node_for_stage(state, resume_point)
804
+ if latest and latest.get("status") == "completed":
805
+ resume_point = compute_next_stage(workflow, resume_point)
806
+
807
+ state["recovery"]["last_validated_checkpoint"] = last_completed
808
+ state["recovery"]["can_resume_from"] = resume_point
809
+ save_state(state, state_file)
810
+
811
+ return {
812
+ "last_completed": last_completed,
813
+ "can_resume_from": resume_point,
814
+ "current_stage": current_stage,
815
+ "current_status": state["current_run"].get("status"),
816
+ }
817
+
818
+
819
+ def action_status(state: dict[str, Any]) -> dict[str, Any]:
820
+ """Return status report by delegating to lc-status logic."""
821
+ import importlib.util
822
+ status_path = PROJECT_ROOT / "scripts" / "lincoln-status.py"
823
+ spec = importlib.util.spec_from_file_location("lincoln_status", status_path)
824
+ if spec and spec.loader:
825
+ mod = importlib.util.module_from_spec(spec)
826
+ spec.loader.exec_module(mod)
827
+ return mod.build_status_report(state)
828
+ try:
829
+ from lincoln_status import build_status_report
830
+ return build_status_report(state)
831
+ except ImportError:
832
+ raise RuntimeError("Could not import lc-status module")
833
+
834
+
835
+ def action_handoff_report(stage_id: str, state: dict[str, Any], state_file: Path) -> str:
836
+ """Generate <process_slug>/handoffs/lc-handoff-{stage}.md and refresh pm-to-ux.handoff.yaml status."""
837
+ template_name = state.get("workflow", {}).get("template")
838
+ workflow = load_workflow(template_name)
839
+ stage_def = find_stage(workflow, stage_id)
840
+
841
+ if _is_legacy_state(state):
842
+ stage_state = state.get("stages", {}).get(stage_id, {})
843
+ artifacts_produced = stage_state.get("artifacts_produced", [])
844
+ else:
845
+ latest_node = get_latest_node_for_stage(state, stage_id)
846
+ stage_state = latest_node or {}
847
+ artifacts_produced = stage_state.get("artifacts", [])
848
+
849
+ lines = []
850
+ lines.append("# Lincoln Handoff Report")
851
+ lines.append("")
852
+ lines.append(f"**Generated:** {now_iso()}")
853
+ lines.append(f"**Branch:** {state.get('current_run', {}).get('branch', 'unknown')}")
854
+ lines.append(f"**Run ID:** {state.get('current_run', {}).get('run_id', 'unknown')}")
855
+ lines.append("")
856
+ lines.append("## Current Stage")
857
+ lines.append(f"- **Stage:** {stage_id}")
858
+ lines.append(f"- **Name:** {stage_def.get('name', stage_id)}")
859
+ if _is_legacy_state(state):
860
+ lines.append(f"- **Status:** {stage_state.get('status', 'unknown')}")
861
+ else:
862
+ lines.append(f"- **Status:** {stage_state.get('status', state.get('current_run', {}).get('status', 'unknown'))}")
863
+ lines.append("")
864
+ lines.append("## Waiting For")
865
+ lines.append(f"- **Waiting for:** {'human' if stage_def.get('human_gate') and not stage_state.get('gate_passed') else 'agent'}")
866
+ lines.append("")
867
+ lines.append("## Artifacts")
868
+ if artifacts_produced:
869
+ lines.append("### Produced")
870
+ for art in artifacts_produced:
871
+ lines.append(f"- [x] {art}")
872
+
873
+ required = [
874
+ interpolate_artifact(str(art), state, resolve_state_path(None))
875
+ for art in stage_def.get("artifacts", [])
876
+ ]
877
+ if required:
878
+ lines.append("### Required")
879
+ for art in required:
880
+ checked = "x" if art in artifacts_produced else " "
881
+ lines.append(f"- [{checked}] {art}")
882
+ lines.append("")
883
+ lines.append("## Next Action")
884
+ lines.append(f"{stage_state.get('status', 'unknown')}")
885
+ lines.append("")
886
+ lines.append("---")
887
+ lines.append("*This file is auto-generated by `scripts/stage_loader.py --action handoff-report`")
888
+ lines.append("*PM→UX handoff contract status is also refreshed in `handoffs/pm-to-ux/pm-to-ux.handoff.yaml`.*")
889
+
890
+ content = "\n".join(lines)
891
+ project_root = state_file.parents[1]
892
+ handoff_dir = process_package_root(state=state, state_path=state_file, project_root=project_root) / "handoffs"
893
+ handoff_dir.mkdir(parents=True, exist_ok=True)
894
+ handoff_path = handoff_dir / f"lc-handoff-{stage_id}.md"
895
+ handoff_path.write_text(content, encoding="utf-8")
896
+
897
+ # Refresh the PM→UX handoff contract status fields if the contract exists.
898
+ pm_to_ux_path = handoff_dir / "pm-to-ux" / "pm-to-ux.handoff.yaml"
899
+ if pm_to_ux_path.exists():
900
+ try:
901
+ contract = load_yaml(pm_to_ux_path)
902
+ if isinstance(contract, dict):
903
+ approval = contract.setdefault("approval", {})
904
+ gate_passed = bool(stage_state.get("gate_passed"))
905
+ approval["pm_confirmed"] = gate_passed
906
+ if gate_passed:
907
+ approval["approved_at"] = stage_state.get("completed_at") or now_iso()
908
+ approval["approved_by"] = stage_state.get("approved_by") or "human-pm"
909
+ else:
910
+ approval["approved_at"] = ""
911
+ approval["approved_by"] = ""
912
+ save_yaml(pm_to_ux_path, contract)
913
+ print(f"Refreshed PM→UX handoff contract: {pm_to_ux_path.relative_to(project_root)}")
914
+ except Exception as exc:
915
+ print(f"WARNING: could not refresh PM→UX handoff contract: {exc}", file=sys.stderr)
916
+
917
+ # Record a structured handoff event in the session trace so benchmark metrics
918
+ # can detect handoffs without regex-parsing the original Bash command.
919
+ lincoln_trace.append_trace_entry(
920
+ state_file,
921
+ "LincolnHandoff",
922
+ {"stage": stage_id},
923
+ 0,
924
+ stage=stage_id,
925
+ run_id=state.get("current_run", {}).get("run_id"),
926
+ )
927
+
928
+ return str(handoff_path.relative_to(project_root))
929
+
930
+
931
+ def action_update_last_updated(state_file: Path) -> None:
932
+ """Update current_run.last_updated_at and persist state."""
933
+ state = load_state(state_file)
934
+ state["current_run"]["last_updated_at"] = now_iso()
935
+ save_state(state, state_file)
936
+
937
+
938
+ def action_benchmark_report(state_file: Path, trigger: str) -> dict[str, str] | None:
939
+ """Generate a benchmark report for the current process state."""
940
+ from scripts import lincoln_benchmark
941
+
942
+ result = lincoln_benchmark.write_benchmark_report(state_file, trigger, PROJECT_ROOT)
943
+ if result is None:
944
+ return None
945
+
946
+ def _rel(path: Path) -> str:
947
+ try:
948
+ return str(path.relative_to(PROJECT_ROOT))
949
+ except ValueError:
950
+ return str(path)
951
+
952
+ return {
953
+ "markdown": _rel(result["markdown"]),
954
+ "json": _rel(result["json"]),
955
+ }
956
+
957
+
958
+ # ---------------------------------------------------------------------------
959
+ # CLI
960
+ # ---------------------------------------------------------------------------
961
+
962
+
963
+ def main() -> int:
964
+ parser = argparse.ArgumentParser(description="Lincoln stage loader")
965
+ parser.add_argument("--stage", help="Stage ID")
966
+ parser.add_argument("--action",
967
+ required=True,
968
+ choices=[
969
+ "load",
970
+ "validate-entry",
971
+ "validate-exit",
972
+ "record-artifacts",
973
+ "approve-gate",
974
+ "append-node",
975
+ "transition-next",
976
+ "recover",
977
+ "status",
978
+ "handoff-report",
979
+ "benchmark-report",
980
+ "update-last-updated",
981
+ ],
982
+ )
983
+ parser.add_argument("--state-file", type=Path, default=None)
984
+ parser.add_argument("--project-root", type=Path, default=PROJECT_ROOT)
985
+ parser.add_argument("--approved-by", default="human-pm", help="Approver name for approve-gate")
986
+ parser.add_argument("--node-id", help="Node ID for append-node")
987
+ parser.add_argument("--status", help="Node status for append-node")
988
+ parser.add_argument("--handoff-file", help="Handoff file path for append-node")
989
+ parser.add_argument("--artifacts", help="Comma-separated artifact paths for append-node")
990
+ parser.add_argument("--trigger", default="manual", help="Trigger type for benchmark-report")
991
+ args = parser.parse_args()
992
+
993
+ state_file = resolve_state_path(args.state_file)
994
+ state = load_state(state_file)
995
+
996
+ if args.action == "load":
997
+ if not args.stage:
998
+ parser.error("--stage is required for load")
999
+ result = action_load(args.stage, state, state_file)
1000
+ print(json.dumps(result, ensure_ascii=False, indent=2))
1001
+ return 0
1002
+
1003
+ if args.action in ("validate-entry", "validate-exit"):
1004
+ if not args.stage:
1005
+ parser.error("--stage is required for validate-*")
1006
+ phase = args.action.split("-")[1]
1007
+ return action_validate(args.stage, state, phase, state_file)
1008
+
1009
+ if args.action == "record-artifacts":
1010
+ if not args.stage:
1011
+ parser.error("--stage is required for record-artifacts")
1012
+ action_record_artifacts(args.stage, state, state_file)
1013
+ return 0
1014
+
1015
+ if args.action == "approve-gate":
1016
+ if not args.stage:
1017
+ parser.error("--stage is required for approve-gate")
1018
+ return action_approve_gate(args.stage, state, state_file, args.approved_by)
1019
+
1020
+ if args.action == "append-node":
1021
+ if not args.node_id:
1022
+ parser.error("--node-id is required for append-node")
1023
+ if not args.status:
1024
+ parser.error("--status is required for append-node")
1025
+ stage_id = args.stage or args.node_id
1026
+ artifacts = [a.strip() for a in args.artifacts.split(",")] if args.artifacts else None
1027
+ return action_append_node(
1028
+ stage_id, state, args.node_id, args.status,
1029
+ state_file, args.handoff_file, artifacts,
1030
+ )
1031
+
1032
+ if args.action == "transition-next":
1033
+ if not args.stage:
1034
+ parser.error("--stage is required for transition-next")
1035
+ action_transition_next(args.stage, state, state_file)
1036
+ return 0
1037
+
1038
+ if args.action == "recover":
1039
+ result = action_recover(state, state_file)
1040
+ print(json.dumps(result, ensure_ascii=False, indent=2))
1041
+ return 0
1042
+
1043
+ if args.action == "status":
1044
+ result = action_status(state)
1045
+ print(json.dumps(result, ensure_ascii=False, indent=2))
1046
+ return 0
1047
+
1048
+ if args.action == "handoff-report":
1049
+ if not args.stage:
1050
+ parser.error("--stage is required for handoff-report")
1051
+ path = action_handoff_report(args.stage, state, state_file)
1052
+ print(f"Handoff report written to: {path}")
1053
+ return 0
1054
+
1055
+ if args.action == "update-last-updated":
1056
+ action_update_last_updated(state_file)
1057
+ return 0
1058
+
1059
+ if args.action == "benchmark-report":
1060
+ result = action_benchmark_report(state_file, args.trigger)
1061
+ if result is None:
1062
+ print("Benchmark report skipped")
1063
+ else:
1064
+ print(f"Benchmark report written to:\n {result['markdown']}\n {result['json']}")
1065
+ return 0
1066
+
1067
+ return 0
1068
+
1069
+
1070
+ if __name__ == "__main__":
1071
+ sys.exit(main())